diff --git a/crates/meta-feeder-sdk/src/cache.rs b/crates/meta-feeder-sdk/src/cache.rs index f22e99e..e04aae4 100644 --- a/crates/meta-feeder-sdk/src/cache.rs +++ b/crates/meta-feeder-sdk/src/cache.rs @@ -53,6 +53,13 @@ const SUBTITLES_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("s /// OpenSubtitles search results keyed by `"\x01"`. Value is the /// resolved subtitle's cid, or the literal `"null"` negative-cache sentinel. const OPENSUBTITLES_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("opensubtitles"); +/// Small durable plugin scratch values that must survive a restart but aren't +/// content-derived — today, the torznab plugin's Prowlarr indexer-set +/// fingerprint, so a set that changed *while the feeder was down* is still +/// detected as a change on the next boot. Keys are plugin-namespaced +/// (`prowlarr:indexer-set`). redb creates the table on open, so an older +/// `.redb` file gains it transparently. +const MISC_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("misc"); /// Per-plugin midhash cache. Cheap to clone (the inner `Database` is shared /// via `Arc`). @@ -105,6 +112,7 @@ impl MidhashCache { tx.open_table(TMDB_PRINCIPAL_TOPN_TABLE)?; tx.open_table(SUBTITLES_TABLE)?; tx.open_table(OPENSUBTITLES_TABLE)?; + tx.open_table(MISC_TABLE)?; tx.commit()?; } Ok(Self { db: Arc::new(db) }) @@ -112,6 +120,14 @@ impl MidhashCache { str_table_accessors!(get_midhash, put_midhash, MIDHASH_TABLE); + str_table_accessors!( + /// Read a durable plugin scratch value (see [`MISC_TABLE`]). + get_misc, + /// Write a durable plugin scratch value (see [`MISC_TABLE`]). + put_misc, + MISC_TABLE, + ); + pub fn entry_count(&self) -> Result { let tx = self.db.begin_read()?; let table = tx.open_table(MIDHASH_TABLE)?; diff --git a/crates/meta-feeder-sdk/src/hash.rs b/crates/meta-feeder-sdk/src/hash.rs index 353f4de..2ed0cde 100644 --- a/crates/meta-feeder-sdk/src/hash.rs +++ b/crates/meta-feeder-sdk/src/hash.rs @@ -183,9 +183,9 @@ const NZB_RELEASE_MAX_DIGEST: usize = 64; /// /// Returns `None` when the id isn't hex or the digest would overflow the /// multihash budget (an oversized host) — the caller drops that row. -pub fn compute_nzb_release_cid(host: &str, release_id: &str) -> Option { +pub fn compute_nzb_release_cid(api_base: &str, release_id: &str) -> Option { let id_bytes = decode_hex_id(release_id)?; - let host_b = host.as_bytes(); + let host_b = api_base.as_bytes(); // identity-multihash digest = varint(host_len) ‖ host ‖ id_bytes let mut digest = Vec::with_capacity(2 + host_b.len() + id_bytes.len()); diff --git a/crates/meta-feeder-sdk/src/meta_core.rs b/crates/meta-feeder-sdk/src/meta_core.rs index 93cfe52..8784f42 100644 --- a/crates/meta-feeder-sdk/src/meta_core.rs +++ b/crates/meta-feeder-sdk/src/meta_core.rs @@ -184,6 +184,48 @@ impl FeederStore { &self.meta_core_url } + /// Raw keys containing `contains` (`GET /api/kv/search`). Returns + /// `(keys, truncated)`; `truncated` means the server capped the result and + /// there may be more. + /// + /// This is a keyspace scan, so it belongs on rare control-plane paths only — + /// today, invalidating the gateway's search-coverage markers when a feeder's + /// upstream source set changes. Never call it per search. + pub async fn search_keys(&self, contains: &str, limit: usize) -> anyhow::Result<(Vec, bool)> { + let url = format!("{}/api/kv/search", self.meta_core_url); + let resp = self + .http + .get(&url) + .query(&[("contains", contains), ("limit", &limit.to_string())]) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + anyhow::bail!("meta-core KV search `{contains}` returned {status}"); + } + #[derive(Deserialize)] + struct KvSearchResponse { + #[serde(default)] + keys: Vec, + #[serde(default)] + truncated: bool, + } + let body: KvSearchResponse = resp.json().await?; + Ok((body.keys, body.truncated)) + } + + /// Delete one raw KV key (`DELETE /api/kv/value?key=`). A missing key is not + /// an error. + pub async fn delete_kv(&self, key: &str) -> anyhow::Result<()> { + let url = format!("{}/api/kv/value", self.meta_core_url); + let resp = self.http.delete(&url).query(&[("key", key)]).send().await?; + let status = resp.status(); + if status.is_success() || status == reqwest::StatusCode::NOT_FOUND { + return Ok(()); + } + anyhow::bail!("meta-core KV DELETE {key} returned {status}"); + } + /// Self-publish a **metadata-only** outcome to meta-core, returning the /// **sparse** outcome (`record: None`) so the gateway's `(_, None)` branch /// skips it — no double write. Byte outcomes and already-sparse outcomes are diff --git a/crates/meta-feeder-sdk/src/plugin.rs b/crates/meta-feeder-sdk/src/plugin.rs index df3583e..f1a36b0 100644 --- a/crates/meta-feeder-sdk/src/plugin.rs +++ b/crates/meta-feeder-sdk/src/plugin.rs @@ -129,6 +129,33 @@ pub fn upstream_id_field(upstream_id: &str) -> String { format!("{upstream_id}id") } +/// Key-set prefix for the provenance `source/