2 Commits
Author SHA1 Message Date
Worph 210e980cc8 genre management
Publish image / publish (push) Failing after 14s
2026-08-01 20:20:54 +02:00
Worph 881b2c38e4 change with the card feeder design
Publish image / publish (push) Failing after 13s
2026-07-26 17:51:38 +02:00
3 changed files with 141 additions and 11 deletions
+58
View File
@@ -27,6 +27,16 @@ pub fn record_matches(fields: &BTreeMap<String, String>, query: &GatewayQuery) -
} }
continue; continue;
} }
// `genres` is a key-set too (`genres/<Name> = "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 { let Some(field_value) = fields.get(key) else {
return false; return false;
}; };
@@ -155,6 +165,54 @@ fn record_has_concrete_language(fields: &BTreeMap<String, String>) -> bool {
/// Key-set members are written as the literal `"true"`; tolerate a couple of /// Key-set members are written as the literal `"true"`; tolerate a couple of
/// obvious truthy spellings and treat anything else as absent. /// 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/<Name>` 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<String, String>, allowed: &[String]) -> bool {
let wanted: Vec<String> = 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 { fn is_truthy_member(v: &str) -> bool {
matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes") matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes")
} }
+77 -8
View File
@@ -36,12 +36,44 @@ pub struct Manifest {
pub message_ids: Vec<String>, pub message_ids: Vec<String>,
} }
/// nntmux shards its NZB storage one level deep by the guid's **first /// How deep we are willing to probe for a shard. nntmux caps its own loop at
/// character** (`storage/nzb/a/abcdef….nzb.gz`). Mirrored here rather than /// 32, but every real deployment sits in the low single digits and a guid is a
/// globbed: a scan of a large store is slow and this is on the compute path. /// 36-char uuid; 8 covers any sane `nzbsplitlevel` while keeping the probe below.
const MAX_SPLIT_LEVEL: usize = 8;
/// Build the path a release's `.nzb.gz` would occupy at an exact shard depth:
/// one directory per leading guid character, mirroring nntmux's
/// `NZB::buildNZBPath()` (`$nzbPath .= $releaseGuid[$i].'/'`).
pub fn nzb_path_at(nzb_root: &Path, guid: &str, levels: usize) -> Option<PathBuf> {
if guid.is_empty() {
return None;
}
let mut path = nzb_root.to_path_buf();
for c in guid.chars().take(levels) {
path.push(c.to_string());
}
Some(path.join(format!("{guid}.nzb.gz")))
}
/// Locate a release's `.nzb.gz` in nntmux's sharded store.
///
/// **The shard depth is an nntmux *setting* (`settings.nzbsplitlevel`), not a
/// constant.** The PHP code's own fallback is 1, but the maintainer image ships
/// a database seeded with **4** (`storage/nzb/9/7/a/2/97a2….nzb.gz`) — so a
/// hardcoded depth silently resolves to a path that does not exist, every
/// release is skipped as "no .nzb.gz on disk yet", and the feeder reports zero
/// hits while the scan is in fact working perfectly. An operator can also
/// change the setting *after* files are written, leaving a store with mixed
/// depths. So resolve by probing the candidate depths, deepest first.
///
/// Still not a glob: at most `MAX_SPLIT_LEVEL + 1` `stat` calls against exact
/// paths, no directory enumeration — cheap enough for the compute path.
pub fn nzb_path(nzb_root: &Path, guid: &str) -> Option<PathBuf> { pub fn nzb_path(nzb_root: &Path, guid: &str) -> Option<PathBuf> {
let first = guid.chars().next()?; let deepest = guid.chars().count().min(MAX_SPLIT_LEVEL);
Some(nzb_root.join(first.to_string()).join(format!("{guid}.nzb.gz"))) (0..=deepest)
.rev()
.filter_map(|levels| nzb_path_at(nzb_root, guid, levels))
.find(|path| path.exists())
} }
/// Read + gunzip a release's `.nzb.gz` and pull out its Message-IDs. /// Read + gunzip a release's `.nzb.gz` and pull out its Message-IDs.
@@ -152,10 +184,47 @@ mod tests {
assert!(ids.iter().all(|i| !i.contains('<') && !i.contains('>'))); assert!(ids.iter().all(|i| !i.contains('<') && !i.contains('>')));
} }
/// One directory per leading guid character, exactly as nntmux's
/// `buildNZBPath()` writes them.
#[test] #[test]
fn nzb_path_shards_on_first_guid_char() { fn nzb_path_at_shards_one_dir_per_leading_char() {
let p = nzb_path(Path::new("/s/nzb"), "abc123").expect("path"); let p = nzb_path_at(Path::new("/s/nzb"), "abc123", 4).expect("path");
assert_eq!(p, Path::new("/s/nzb/a/abc123.nzb.gz")); assert_eq!(p, Path::new("/s/nzb/a/b/c/1/abc123.nzb.gz"));
let p0 = nzb_path_at(Path::new("/s/nzb"), "abc123", 0).expect("path");
assert_eq!(p0, Path::new("/s/nzb/abc123.nzb.gz"));
}
/// The regression this exists for: the maintainer image seeds
/// `nzbsplitlevel = 4`, so a resolver hardcoded to depth 1 finds nothing and
/// every real release is silently skipped as "no .nzb.gz on disk yet".
#[test]
fn nzb_path_finds_a_level_4_shard() {
let dir = tempfile::tempdir().expect("tmp");
let guid = "97a2218c-d59b-4bc0-bb67-8a7d41e93fb5";
let deep = dir.path().join("9/7/a/2");
std::fs::create_dir_all(&deep).expect("mkdir");
std::fs::write(deep.join(format!("{guid}.nzb.gz")), b"x").expect("write");
let got = nzb_path(dir.path(), guid).expect("resolved");
assert_eq!(got, deep.join(format!("{guid}.nzb.gz")));
}
/// A store written at a shallower depth (or an operator who lowered
/// `nzbsplitlevel` after the fact) must still resolve.
#[test]
fn nzb_path_finds_a_level_1_shard() {
let dir = tempfile::tempdir().expect("tmp");
std::fs::create_dir_all(dir.path().join("a")).expect("mkdir");
std::fs::write(dir.path().join("a/abc123.nzb.gz"), b"x").expect("write");
let got = nzb_path(dir.path(), "abc123").expect("resolved");
assert_eq!(got, dir.path().join("a/abc123.nzb.gz"));
}
#[test]
fn nzb_path_is_none_when_absent_at_every_depth() {
let dir = tempfile::tempdir().expect("tmp");
assert!(nzb_path(dir.path(), "abc123").is_none());
} }
/// A release row can exist before `createNZBs()` has written the manifest. /// A release row can exist before `createNZBs()` has written the manifest.
+6 -3
View File
@@ -378,9 +378,12 @@ impl FeederPlugin for UsenetPlugin {
.with_help( .with_help(
"Path to nntmux's NZB directory as mounted INTO this container \ "Path to nntmux's NZB directory as mounted INTO this container \
— it must be the same volume nntmux's PATH_TO_NZBS points at. \ — it must be the same volume nntmux's PATH_TO_NZBS points at. \
Each release's ordered Message-IDs survive only in \ Each release's ordered Message-IDs survive only in the on-disk \
<root>/<first-guid-char>/<guid>.nzb.gz, so a wrong path here \ <guid>.nzb.gz, so a wrong path here means every release \
means every release resolves to NotFound.", resolves to NotFound. Point it at the STORE ROOT, not a shard: \
nntmux nests each file one directory per leading guid character, \
`nzbsplitlevel` deep (the image ships 4 → 9/7/a/2/97a2….nzb.gz), \
and the depth is detected automatically.",
) )
.required(), .required(),
], ],