align to metadata keys doc

This commit is contained in:
Worph
2026-08-30 19:50:25 +02:00
parent b823654b9a
commit aea258c803
5 changed files with 292 additions and 0 deletions
+275
View File
@@ -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());
}
}
+1
View File
@@ -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;
@@ -178,6 +178,12 @@ fn build_seed_record(
// mode marker, contentKind, and optionally anime.
fields.insert(mode.marker().to_string(), "true".to_string());
fields.insert("contentKind".to_string(), content_kind.to_string());
// The routing axis, co-written with the kind it derives from
// (METADATA_KEYS.md §1): `fileType` says what the bytes are, `domain` says
// which app wants the record.
if let Some(d) = meta_feeder_sdk::domain::domain_for_content_kind(content_kind) {
fields.insert("domain".to_string(), d.to_string());
}
fields.insert("fileType".to_string(), "video".to_string());
if anime {
fields.insert("anime".to_string(), "true".to_string());
@@ -548,6 +548,7 @@ fn build_stub_patch(f: &BTreeMap<String, String>) -> Option<EnrichOutcome> {
set.insert("fileType".to_string(), "video".to_string());
set.insert("videoType".to_string(), "movie".to_string());
set.insert("contentKind".to_string(), "movie".to_string());
set.insert("domain".to_string(), "film".to_string());
}
if !has_poster {
// Non-resolvable marker — the image 404s (broken tile), but the card
@@ -773,6 +774,10 @@ async fn build_enrichment_patch(
match matched_kind {
TmdbKind::Movie => {
set.insert("contentKind".to_string(), "movie".to_string());
// The routing axis moves with the kind: a TMDB movie match is
// `domain=film`, and reconciling the kind without the domain would
// leave the record routed as a series.
set.insert("domain".to_string(), "film".to_string());
// Co-write the narrower `videoType` facet (METADATA_KEYS.md §14.11
// — contentKind=movie ↔ videoType=movie). Overwrites any
// category-derived videoType from `into_discovery_record` with the
@@ -784,6 +789,7 @@ async fn build_enrichment_patch(
}
TmdbKind::Tv => {
set.insert("contentKind".to_string(), "episode".to_string());
set.insert("domain".to_string(), "tv".to_string());
// contentKind=episode ↔ videoType=tvshow (METADATA_KEYS.md §14.11).
set.insert("videoType".to_string(), "tvshow".to_string());
// Season/episode boundary validation. Only a parsed `season`
@@ -530,6 +530,9 @@ impl TriblerPlugin {
"movie"
};
rec.fields.insert("contentKind".to_string(), kind.to_string());
if let Some(d) = meta_feeder_sdk::domain::domain_for_content_kind(kind) {
rec.fields.insert("domain".to_string(), d.to_string());
}
}
// For episode records, stamp the *parsed* season/episode so the consumer
// renders a proper series with an ordered episode list (meta-watch keys
@@ -646,6 +649,7 @@ impl TriblerPlugin {
// resolves the show as TV (the show title drives the TMDB search).
r.fields
.insert("contentKind".to_string(), "episode".to_string());
r.fields.insert("domain".to_string(), "tv".to_string());
}
r
};