align to metadata keys doc
This commit is contained in:
@@ -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<crate::query::GatewayQuery> {
|
||||||
|
let domains = query.filters.get("domain")?;
|
||||||
|
let mut out = query.clone();
|
||||||
|
out.filters.remove("domain");
|
||||||
|
if !out.filters.contains_key("contentKind") {
|
||||||
|
let kinds: Vec<String> = 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<String, String>) {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.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<String, String> = 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<String, String> = 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<String, String> = BTreeMap::new();
|
||||||
|
stamp_domain(&mut n);
|
||||||
|
assert!(n.get("domain").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ pub const DEFAULT_META_CORE_URL: &str = "http://metacore-app:9000";
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod domain;
|
||||||
pub mod enrich;
|
pub mod enrich;
|
||||||
pub mod filename_meta;
|
pub mod filename_meta;
|
||||||
pub mod hash;
|
pub mod hash;
|
||||||
|
|||||||
@@ -181,7 +181,14 @@ impl Card {
|
|||||||
// contentKind axis stay orthogonal, so `fileType:card contentKind:series`
|
// contentKind axis stay orthogonal, so `fileType:card contentKind:series`
|
||||||
// is a well-formed query and routing needs no special case.
|
// is a well-formed query and routing needs no special case.
|
||||||
fields.insert("fileType".to_string(), "card".to_string());
|
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());
|
fields.insert("title".to_string(), self.title.clone());
|
||||||
|
|
||||||
// The id bag.
|
// The id bag.
|
||||||
|
|||||||
Reference in New Issue
Block a user