From aea258c8030503db753090d7435445c984f39e49 Mon Sep 17 00:00:00 2001 From: Worph Date: Sun, 30 Aug 2026 19:50:25 +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/tribler-feeder/src/discovery.rs | 6 + feeder-plugin/tribler-feeder/src/enrich.rs | 6 + .../tribler-feeder/src/tribler/mod.rs | 4 + 5 files changed, 292 insertions(+) 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/tribler-feeder/src/discovery.rs b/feeder-plugin/tribler-feeder/src/discovery.rs index 31a0963..9116845 100644 --- a/feeder-plugin/tribler-feeder/src/discovery.rs +++ b/feeder-plugin/tribler-feeder/src/discovery.rs @@ -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()); diff --git a/feeder-plugin/tribler-feeder/src/enrich.rs b/feeder-plugin/tribler-feeder/src/enrich.rs index 9f1ebd0..bdd1df8 100644 --- a/feeder-plugin/tribler-feeder/src/enrich.rs +++ b/feeder-plugin/tribler-feeder/src/enrich.rs @@ -548,6 +548,7 @@ fn build_stub_patch(f: &BTreeMap) -> Option { 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` diff --git a/feeder-plugin/tribler-feeder/src/tribler/mod.rs b/feeder-plugin/tribler-feeder/src/tribler/mod.rs index cff263e..26a38cb 100644 --- a/feeder-plugin/tribler-feeder/src/tribler/mod.rs +++ b/feeder-plugin/tribler-feeder/src/tribler/mod.rs @@ -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 };