Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
210e980cc8 | ||
|
|
881b2c38e4 |
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user