From fadfebbf9bf74cb178ce02412b447ea144a1335b Mon Sep 17 00:00:00 2001 From: Worph Date: Sat, 1 Aug 2026 20:21:07 +0200 Subject: [PATCH] genre management --- crates/meta-feeder-sdk/src/query_eval.rs | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/meta-feeder-sdk/src/query_eval.rs b/crates/meta-feeder-sdk/src/query_eval.rs index 523187d..c0d5f81 100644 --- a/crates/meta-feeder-sdk/src/query_eval.rs +++ b/crates/meta-feeder-sdk/src/query_eval.rs @@ -27,6 +27,16 @@ pub fn record_matches(fields: &BTreeMap, query: &GatewayQuery) - } continue; } + // `genres` is a key-set too (`genres/ = "true"`, METADATA_KEYS + // ยง`genres/{name}`) on everything the card tier writes. Without this arm + // a `genres:` filter looks up a flat `genres` field, finds nothing on a + // card, and drops it โ€” so a genre-scoped row would return empty. + if key == "genres" { + if !genres_filter_matches(fields, allowed) { + return false; + } + continue; + } let Some(field_value) = fields.get(key) else { return false; }; @@ -155,6 +165,54 @@ fn record_has_concrete_language(fields: &BTreeMap) -> bool { /// Key-set members are written as the literal `"true"`; tolerate a couple of /// obvious truthy spellings and treat anything else as absent. +/// Fold a genre label to its comparison form: lowercase, alphanumerics only. +/// +/// Genre display names carry spaces and ampersands (`"Action & Adventure"`, +/// `"Sci-Fi & Fantasy"`, `"Science Fiction"`), and **space is the query DSL's +/// token separator** โ€” so a filter can never spell a genre the way the record +/// stores it. Callers pass a slug (`genres:action-adventure`); folding both +/// sides makes them one value without forcing the stored name to be +/// machine-shaped. +fn genre_fold(s: &str) -> String { + s.chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +/// True iff the record carries one of the requested genres, in **either** +/// encoding: a truthy `genres/` key-set member (the card tier's shape) or +/// the legacy flat comma-joined `genres` field. OR across the requested genres, +/// both sides folded โ€” see [`genre_fold`]. +/// +/// **Fails closed, unlike [`languages_filter_matches`].** A language filter is a +/// viewer *preference* narrowing a set, so an unknown-language release is kept. +/// A genre filter *defines what a row is*: a record with no genre information is +/// not known to be Action, so putting it in an Action row states something +/// untrue. Upstream has already filtered by genre, so this is a re-validation. +fn genres_filter_matches(fields: &BTreeMap, allowed: &[String]) -> bool { + let wanted: Vec = allowed.iter().map(|g| genre_fold(g)).collect(); + if wanted.iter().all(|g| g.is_empty()) { + return true; // a filter with no usable value constrains nothing + } + for (key, value) in fields { + let Some(name) = key.strip_prefix("genres/") else { + continue; + }; + if is_truthy_member(value) && wanted.contains(&genre_fold(name)) { + return true; + } + } + if let Some(flat) = fields.get("genres") { + for member in flat.split(|c| c == ',' || c == '|') { + if wanted.contains(&genre_fold(member)) { + return true; + } + } + } + false +} + fn is_truthy_member(v: &str) -> bool { matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes") }