From 759a336bb85e1d1dd38baea7b40fae6f58b52ab9 Mon Sep 17 00:00:00 2001 From: Worph Date: Sun, 30 Aug 2026 19:50:37 +0200 Subject: [PATCH] align to metadata keys doc --- crates/meta-feeder-sdk/src/domain.rs | 275 ++++++++++++++++++++++++++ crates/meta-feeder-sdk/src/lib.rs | 1 + feeder-plugin/card-feeder/src/card.rs | 9 +- 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 crates/meta-feeder-sdk/src/domain.rs diff --git a/crates/meta-feeder-sdk/src/domain.rs b/crates/meta-feeder-sdk/src/domain.rs new file mode 100644 index 0000000..5aaae44 --- /dev/null +++ b/crates/meta-feeder-sdk/src/domain.rs @@ -0,0 +1,275 @@ +//! `contentKind` → `domain` — the routing table from METADATA_KEYS.md §1. +//! +//! `domain` answers **"which application is this record destined for?"**. It +//! is the coarsest facet in the registry and the key every client filters its +//! catalogue on: +//! +//! ```text +//! meta-watch → domain:film|tv +//! meta-listen → domain:music +//! meta-read → domain:literature +//! ``` +//! +//! # Why it is stored and not derived on read +//! +//! `contentKind`'s vocabulary is deliberately open — a new feeder may mint a +//! kind and ship without a fleet-wide update. That only stays safe because the +//! domain travels *with* the record: a peer that has never heard of +//! `podcastEpisode` still routes it correctly, because it never has to +//! interpret the token. Deriving on read would silently drop every record +//! whose kind the reader predates. +//! +//! # ⚠ The binding is permanent and one-way +//! +//! Add kinds freely; **never re-map an existing kind to a different domain**. +//! Because the value is stored, two peers on different versions that disagree +//! about where `audiobook` belongs produce a split-brain that redeploying +//! cannot heal — the wrong domain is already written into records across the +//! mesh. If a kind genuinely has to move, mint a new kind. +//! +//! # Why `domain` and not one kind-key per medium +//! +//! `record_matches` is AND-across-keys / OR-within-key, and a non-matching key +//! fails the record outright. A per-medium split (`videoKind` / `audioKind` / +//! `documentKind`) would force meta-listen to express "music video **or** +//! audio track" as an OR across two *different* keys — which the query +//! language cannot represent at all. One domain key, OR-ed within itself, +//! always can. + +/// The `contentKind` → `domain` table. A kind absent from this list has no +/// domain and is **not independently routable** — correct for sidecars +/// (subtitles, artwork) and for un-anchored hits, a producer bug for anything +/// else. +/// +/// ⚠ `pack` is deliberately absent. A season pack and an album release are the +/// same structural thing, so the kind alone cannot tell `tv` from `music`; the +/// writer stamps `pack`'s domain from its own context (the same reason +/// `derive_file_type` cannot have a second `pack` arm). +const DOMAIN_BY_CONTENT_KIND: &[(&str, &str)] = &[ + // film / tv + ("movie", "film"), + ("series", "tv"), + ("episode", "tv"), + // music + ("track", "music"), + ("album", "music"), + ("artist", "music"), + ("musicVideo", "music"), + ("djMix", "music"), + ("liveSet", "music"), + // spoken + ("podcast", "spoken"), + ("podcastEpisode", "spoken"), + // literature — an audiobook's identity is the book (same author, same ISBN + // family, same series as the ebook), so meta-read groups the two editions + // on one page. The player does not decide the domain. + ("book", "literature"), + ("audiobook", "literature"), + ("comic", "literature"), + ("manga", "literature"), + ("magazine", "literature"), + // science + ("paper", "science"), +]; + +/// Resolve the domain a `contentKind` belongs to. +/// +/// Returns `None` for `pack` (not derivable from the kind — the writer supplies +/// it), for format/sidecar kinds that route nowhere, and for any kind this +/// build has never heard of. A caller that already knows the domain from its +/// own context must prefer that over this table. +pub fn domain_for_content_kind(content_kind: &str) -> Option<&'static str> { + let k = content_kind.trim(); + DOMAIN_BY_CONTENT_KIND + .iter() + .find(|(kind, _)| kind.eq_ignore_ascii_case(k)) + .map(|(_, domain)| *domain) +} + +/// The inverse: every `contentKind` that belongs to `domain`. +/// +/// Used at the gateway-routing boundary. A gateway declares the *content kinds* +/// it serves (`served_content_kinds`), not domains, so a `domain:` filter is +/// matched by expanding it here rather than by adding a `served_domains` field +/// to the heartbeat — which would be a wire-protocol change requiring every +/// feeder manifest and every peer to move at once. +pub fn content_kinds_for_domain(domain: &str) -> Vec<&'static str> { + let d = domain.trim(); + DOMAIN_BY_CONTENT_KIND + .iter() + .filter(|(_, dom)| dom.eq_ignore_ascii_case(d)) + .map(|(kind, _)| *kind) + .collect() +} + +/// Rewrite a query so a `domain:` filter can reach a plugin that has never +/// heard of the key. +/// +/// Returns `None` when the query carries no `domain:` filter — the common +/// path, and cheaper than cloning. +/// +/// # Why translate instead of teaching every plugin +/// +/// `record_matches` fails a record outright when a filter key is missing from +/// its fields, and `domain` is missing from every record a not-yet-updated +/// feeder emits. Nine of the fifteen feeders pin the SDK by git tag, so they +/// cannot pick up a new key on our schedule. Translating `domain:literature` +/// into the `contentKind:` alternation it covers means the wire query a plugin +/// sees never mentions `domain` at all, and old plugins keep working unchanged +/// — while the dispatcher still enforces the real `domain:` filter afterwards, +/// against records it has just stamped. +/// +/// An explicit `contentKind:` filter already in the query is left alone: it is +/// the narrower of the two, and the `domain` term is still enforced +/// post-stamp, so the AND semantics hold either way. +pub fn expand_domain_filter(query: &crate::query::GatewayQuery) -> Option { + let domains = query.filters.get("domain")?; + let mut out = query.clone(); + out.filters.remove("domain"); + if !out.filters.contains_key("contentKind") { + let kinds: Vec = domains + .iter() + .flat_map(|d| content_kinds_for_domain(d)) + .map(|k| k.to_string()) + .collect(); + // An unknown domain expands to nothing. Inserting an empty + // alternation would drop every record at the plugin; leaving the + // filter out lets the plugin answer and the post-stamp `domain:` + // check do the (correct, empty) filtering. + if !kinds.is_empty() { + out.filters.insert("contentKind".to_string(), kinds); + } + } + Some(out) +} + +/// Stamp `domain` on a field map that already carries a `contentKind`. +/// +/// The convention from METADATA_KEYS.md §1: **whoever writes `contentKind` +/// writes `domain` in the same breath**. An existing `domain` is never +/// overwritten — a writer that knows better than the table (a `pack`, say) has +/// already set it. +pub fn stamp_domain(fields: &mut std::collections::BTreeMap) { + if fields.contains_key("domain") { + return; + } + let Some(kind) = fields.get("contentKind").cloned() else { + return; + }; + if let Some(domain) = domain_for_content_kind(&kind) { + fields.insert("domain".to_string(), domain.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn kinds_map_to_their_domain() { + assert_eq!(domain_for_content_kind("movie"), Some("film")); + assert_eq!(domain_for_content_kind("episode"), Some("tv")); + assert_eq!(domain_for_content_kind("track"), Some("music")); + // The case that proves medium and domain are different axes. + assert_eq!(domain_for_content_kind("musicVideo"), Some("music")); + // The case that proves the player does not decide the domain. + assert_eq!(domain_for_content_kind("audiobook"), Some("literature")); + } + + #[test] + fn pack_has_no_derivable_domain() { + assert_eq!(domain_for_content_kind("pack"), None); + } + + #[test] + fn unknown_kind_is_none_not_a_guess() { + assert_eq!(domain_for_content_kind("hologram"), None); + } + + #[test] + fn expansion_is_the_inverse() { + let music = content_kinds_for_domain("music"); + assert!(music.contains(&"track")); + assert!(music.contains(&"musicVideo")); + assert!(!music.contains(&"audiobook")); + for kind in content_kinds_for_domain("literature") { + assert_eq!(domain_for_content_kind(kind), Some("literature")); + } + } + + fn q(filters: &[(&str, &[&str])]) -> crate::query::GatewayQuery { + crate::query::GatewayQuery { + raw_text: String::new(), + free_text: String::new(), + filters: filters + .iter() + .map(|(k, vs)| { + ( + k.to_string(), + vs.iter().map(|v| v.to_string()).collect::>(), + ) + }) + .collect(), + ranges: Vec::new(), + negations: Vec::new(), + } + } + + #[test] + fn no_domain_filter_is_left_untouched() { + assert!(expand_domain_filter(&q(&[("fileType", &["video"])])).is_none()); + } + + #[test] + fn domain_becomes_the_content_kinds_it_covers() { + let out = expand_domain_filter(&q(&[("domain", &["literature"])])).unwrap(); + // The plugin never sees `domain:` — that is the whole point. + assert!(!out.filters.contains_key("domain")); + let kinds = out.filters.get("contentKind").unwrap(); + assert!(kinds.contains(&"book".to_string())); + assert!(kinds.contains(&"audiobook".to_string())); + assert!(!kinds.contains(&"track".to_string())); + } + + #[test] + fn an_explicit_content_kind_filter_wins() { + let out = expand_domain_filter(&q(&[ + ("domain", &["music"][..]), + ("contentKind", &["track"][..]), + ])) + .unwrap(); + assert_eq!( + out.filters.get("contentKind").map(Vec::as_slice), + Some(&["track".to_string()][..]) + ); + } + + #[test] + fn unknown_domain_adds_no_alternation() { + // An empty alternation would drop every record at the plugin; the + // post-stamp `domain:` check does the (correct, empty) filtering. + let out = expand_domain_filter(&q(&[("domain", &["hologram"])])).unwrap(); + assert!(!out.filters.contains_key("contentKind")); + } + + #[test] + fn stamp_fills_in_and_never_overwrites() { + let mut f: BTreeMap = BTreeMap::new(); + f.insert("contentKind".into(), "episode".into()); + stamp_domain(&mut f); + assert_eq!(f.get("domain").map(String::as_str), Some("tv")); + + // A pack writer that already knows its context keeps it. + let mut p: BTreeMap = BTreeMap::new(); + p.insert("contentKind".into(), "pack".into()); + p.insert("domain".into(), "music".into()); + stamp_domain(&mut p); + assert_eq!(p.get("domain").map(String::as_str), Some("music")); + + // No contentKind → no domain invented. + let mut n: BTreeMap = BTreeMap::new(); + stamp_domain(&mut n); + assert!(n.get("domain").is_none()); + } +} diff --git a/crates/meta-feeder-sdk/src/lib.rs b/crates/meta-feeder-sdk/src/lib.rs index dd62603..3ec6708 100644 --- a/crates/meta-feeder-sdk/src/lib.rs +++ b/crates/meta-feeder-sdk/src/lib.rs @@ -24,6 +24,7 @@ pub const DEFAULT_META_CORE_URL: &str = "http://metacore-app:9000"; pub mod cache; pub mod common; pub mod config; +pub mod domain; pub mod enrich; pub mod filename_meta; pub mod hash; diff --git a/feeder-plugin/card-feeder/src/card.rs b/feeder-plugin/card-feeder/src/card.rs index b819040..025922d 100644 --- a/feeder-plugin/card-feeder/src/card.rs +++ b/feeder-plugin/card-feeder/src/card.rs @@ -181,7 +181,14 @@ impl Card { // contentKind axis stay orthogonal, so `fileType:card contentKind:series` // is a well-formed query and routing needs no special case. fields.insert("fileType".to_string(), "card".to_string()); - fields.insert("contentKind".to_string(), self.content_kind().to_string()); + let content_kind = self.content_kind(); + fields.insert("contentKind".to_string(), content_kind.to_string()); + // Third axis: which app the card is destined for. `fileType=card` says + // "no bytes", `contentKind` says which work, `domain` says who wants it + // — meta-watch's wall is a `domain:film|tv` query (METADATA_KEYS.md §1). + if let Some(domain) = meta_feeder_sdk::domain::domain_for_content_kind(content_kind) { + fields.insert("domain".to_string(), domain.to_string()); + } fields.insert("title".to_string(), self.title.clone()); // The id bag.