7 Commits
Author SHA1 Message Date
Worph b0ff2b55c8 SDK v1.7.0 by git tag (vendored copy removed); advertises over beacon v2 as metamesh.feeder/<upstream>
Publish image / publish (push) Failing after 13s
2026-10-04 17:35:22 +00:00
Worph bdeecd25a0 align with metadata standard
Publish image / publish (push) Failing after 13s
2026-09-13 15:53:06 +02:00
Worph 5f5ed1f483 rework domain field
Publish image / publish (push) Failing after 13s
2026-09-02 00:02:48 +02:00
Worph 759a336bb8 align to metadata keys doc 2026-08-30 19:50:37 +02:00
Worph c136d8a140 rework plugin 2026-08-24 16:32:11 +02:00
Worph ee4036f93f audio support widening 2026-08-21 18:21:32 +02:00
Worph 631beb1141 genre management
Publish image / publish (push) Failing after 14s
2026-08-01 20:22:06 +02:00
30 changed files with 1720 additions and 6251 deletions
+2 -2
View File
@@ -3,8 +3,8 @@ name: Publish image
# Build and push the feeder image to the GitHub Container Registry (ghcr.io):
# * every push to main -> the moving `main` tag
# * every `v*` tag -> semver tags (e.g. v1.0.0 -> 1.0.0, 1.0, 1)
# The build context is the repo root (the workspace) so the vendored
# `meta-feeder-sdk` path dependency resolves; the Dockerfile lives under
# The build context is the repo root (the workspace; `meta-feeder-sdk` is a
# git dependency pinned by tag); the Dockerfile lives under
# feeder-plugin/card-feeder/.
on:
push:
Generated
+3 -2
View File
@@ -761,7 +761,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "meta-feeder-sdk"
version = "0.1.0"
version = "1.7.0"
source = "git+https://github.com/worph/meta-feeder-sdk?tag=v1.7.0#027917c8cffe7ca43dadcf3b426cc5f69ecc443a"
dependencies = [
"anyhow",
"async-trait",
@@ -770,13 +771,13 @@ dependencies = [
"bytes",
"futures",
"hyper",
"libc",
"redb",
"regex",
"reqwest",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tracing",
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/*", "feeder-plugin/*"]
members = ["feeder-plugin/*"]
[workspace.package]
edition = "2021"
+1 -2
View File
@@ -70,7 +70,7 @@ first boot; the config file wins from then on. No hot reload — bounce the feed
cargo build --release --bin card-feeder
cargo test -p card-feeder
# Image (build context is the repo root so the vendored SDK path dep resolves)
# Image (build context is the repo root, the workspace)
docker build -f feeder-plugin/card-feeder/Dockerfile -t ghcr.io/worph/meta-feeder-card:dev .
```
@@ -84,7 +84,6 @@ Register it with a gateway by adding it to that gateway's `gateway-config.json`
## Layout
```
crates/meta-feeder-sdk/ vendored SDK copy (mirrored from meta-gateway)
feeder-plugin/card-feeder/
src/tmdb.rs the FeederPlugin impl — routing + query + outcomes
src/card.rs the Card model + its DiscoveryRecord projection
-49
View File
@@ -1,49 +0,0 @@
[package]
name = "meta-feeder-sdk"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
# Source-agnostic foundation shared by the gateway core and every feeder
# sidecar binary: the `FeederPlugin` trait, the content-addressing hash
# families, the per-plugin redb cache, the shared HTTP/cache helpers, the
# structured-query evaluator, and the axum `serve_feeders` harness that wraps
# any set of `FeederPlugin`s in the feeder HTTP contract.
#
# Deliberately libp2p-free / blockstore-free: a feeder finds + fetches bytes;
# the gateway core owns hashing-into-the-blockstore, the meta-core store-back,
# and the libp2p wire. Keep heavy deps (librqbit, beetswap, cid, libp2p) OUT
# of here so feeders that don't need them don't pay for them.
[dependencies]
# Async runtime + HTTP server (the serve_feeders harness)
tokio = { version = "1.40", features = ["full"] }
axum = { version = "0.7", features = ["json"] }
hyper = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
# Utilities
futures = "0.3"
async-trait = "0.1"
anyhow = "1"
thiserror = "1"
tracing = "0.1"
sha2 = "0.10"
bytes = "1"
# Title-tag parsing (shared filename_meta — season/episode/quality/codec/languages).
regex = "1"
# Per-plugin midhash/blob cache. Schema is identical to meta-share v1 — see
# cache.rs. Pure-Rust single-process embedded store.
redb = "2"
[dev-dependencies]
tempfile = "3"
tokio = { version = "1.40", features = ["full", "test-util"] }
-337
View File
@@ -1,337 +0,0 @@
//! Per-plugin midhash cache backed by redb.
//!
//! Each plugin gets its own redb file at
//! `<state_dir>/gateway/<upstream_id>/cache.redb`. Keys are upstream
//! `record_id`s (DOI for sci-hub, book id for gutenberg, …); values are the
//! `Midhash` strings the plugin computed by fetching + hashing the upstream
//! file.
//!
//! The schema is identical to meta-share v1's so old `.redb` files copy
//! across. redb is synchronous and sub-millisecond for small ops, so calls
//! run inline from async handlers without `spawn_blocking`.
use std::path::Path;
use std::sync::Arc;
use redb::{Database, ReadableTableMetadata, TableDefinition};
pub const CACHE_FILENAME: &str = "cache.redb";
const MIDHASH_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("midhash");
const BLOBS_TABLE: TableDefinition<'_, &str, &[u8]> = TableDefinition::new("blobs");
const COVER_CID_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("cover_cid");
const BIBREC_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("bibrec");
const PREVIEW_CID_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("preview_cid");
/// Full-file sha2-256 IPFS cid for a previously-fetched torrent payload
/// (torznab's bt-fetch path). Distinct from `MIDHASH_TABLE` because the
/// hash families differ: `midhash` is the synthetic midhash256-from-infohash
/// fallback; `fullhash` records "we actually downloaded the bytes and this is
/// the IPFS CIDv1 over them."
const FULLHASH_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("fullhash");
/// Enumerated torrent file list (JSON `Vec<TorrentFile>`) keyed by the
/// torrent's `record_id` (infohash).
const FILELIST_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("filelist");
/// Cached TMDB *search* results keyed by a stable lookup key. The value is the
/// JSON encoding of the top `TmdbHit`, or the literal `"null"` negative-cache
/// sentinel.
const TMDB_SEARCH_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("tmdb_search");
/// Cached TMDB `GET /3/tv/{id}` structural details keyed by `tmdbid`.
const TMDB_TVDETAILS_TABLE: TableDefinition<'_, &str, &str> =
TableDefinition::new("tmdb_tvdetails");
/// Cached TMDB `GET /3/{tv,movie}/{id}/external_ids` keyed by `tmdbid`.
const TMDB_EXTIDS_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("tmdb_extids");
/// Cached TMDB `GET /3/movie/{id}` details keyed by `tmdbid`.
const TMDB_MOVIEDETAILS_TABLE: TableDefinition<'_, &str, &str> =
TableDefinition::new("tmdb_moviedetails");
/// Cached **ranked** TMDB `search/multi` anchors keyed by the normalized
/// free-text query.
const TMDB_PRINCIPAL_TOPN_TABLE: TableDefinition<'_, &str, &str> =
TableDefinition::new("tmdb_principal_topn");
/// Subtitle linkage discovered at search-enrich time, keyed by the torrent's
/// `record_id` (infohash). Value is the JSON encoding of a `Vec<SubtitleLink>`.
const SUBTITLES_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("subtitles");
/// OpenSubtitles search results keyed by `"<tmdb_id>\x01<lang3>"`. Value is the
/// resolved subtitle's cid, or the literal `"null"` negative-cache sentinel.
const OPENSUBTITLES_TABLE: TableDefinition<'_, &str, &str> = TableDefinition::new("opensubtitles");
/// Per-plugin midhash cache. Cheap to clone (the inner `Database` is shared
/// via `Arc`).
#[derive(Clone)]
pub struct MidhashCache {
db: Arc<Database>,
}
/// Generate the standard `get`/`put` accessor pair for a `&str → &str` redb
/// table.
macro_rules! str_table_accessors {
($(#[$gmeta:meta])* $get:ident, $(#[$pmeta:meta])* $put:ident, $table:ident $(,)?) => {
$(#[$gmeta])*
pub fn $get(&self, key: &str) -> Result<Option<String>, redb::Error> {
let tx = self.db.begin_read()?;
let table = tx.open_table($table)?;
Ok(table.get(key)?.map(|v| v.value().to_string()))
}
$(#[$pmeta])*
pub fn $put(&self, key: &str, value: &str) -> Result<(), redb::Error> {
let tx = self.db.begin_write()?;
{
let mut table = tx.open_table($table)?;
table.insert(key, value)?;
}
tx.commit()?;
Ok(())
}
};
}
impl MidhashCache {
pub fn open(cache_dir: &Path) -> Result<Self, redb::Error> {
let path = cache_dir.join(CACHE_FILENAME);
let db = Database::create(&path)?;
{
let tx = db.begin_write()?;
tx.open_table(MIDHASH_TABLE)?;
tx.open_table(BLOBS_TABLE)?;
tx.open_table(COVER_CID_TABLE)?;
tx.open_table(BIBREC_TABLE)?;
tx.open_table(PREVIEW_CID_TABLE)?;
tx.open_table(FULLHASH_TABLE)?;
tx.open_table(FILELIST_TABLE)?;
tx.open_table(TMDB_SEARCH_TABLE)?;
tx.open_table(TMDB_TVDETAILS_TABLE)?;
tx.open_table(TMDB_EXTIDS_TABLE)?;
tx.open_table(TMDB_MOVIEDETAILS_TABLE)?;
tx.open_table(TMDB_PRINCIPAL_TOPN_TABLE)?;
tx.open_table(SUBTITLES_TABLE)?;
tx.open_table(OPENSUBTITLES_TABLE)?;
tx.commit()?;
}
Ok(Self { db: Arc::new(db) })
}
str_table_accessors!(get_midhash, put_midhash, MIDHASH_TABLE);
pub fn entry_count(&self) -> Result<u64, redb::Error> {
let tx = self.db.begin_read()?;
let table = tx.open_table(MIDHASH_TABLE)?;
Ok(table.len()?)
}
pub fn get_blob(&self, cid: &str) -> Result<Option<Vec<u8>>, redb::Error> {
let tx = self.db.begin_read()?;
let table = tx.open_table(BLOBS_TABLE)?;
Ok(table.get(cid)?.map(|v| v.value().to_vec()))
}
pub fn put_blob(&self, cid: &str, bytes: &[u8]) -> Result<(), redb::Error> {
let tx = self.db.begin_write()?;
{
let mut table = tx.open_table(BLOBS_TABLE)?;
table.insert(cid, bytes)?;
}
tx.commit()?;
Ok(())
}
str_table_accessors!(get_cover_cid, put_cover_cid, COVER_CID_TABLE);
str_table_accessors!(
/// Read a previously-stored full-file IPFS cid for `record_id`.
get_fullhash,
/// Record the IPFS cid produced by a successful full fetch.
put_fullhash,
FULLHASH_TABLE
);
str_table_accessors!(
/// Read the cached torrent file list (JSON) for `record_id`.
get_filelist,
/// Cache the enumerated torrent file list (JSON `Vec<TorrentFile>`).
put_filelist,
FILELIST_TABLE
);
str_table_accessors!(
/// Read the cached subtitle linkage (JSON `Vec<SubtitleLink>`).
get_subtitles,
/// Cache the discovered subtitle linkage (JSON `Vec<SubtitleLink>`).
put_subtitles,
SUBTITLES_TABLE
);
str_table_accessors!(
/// Read a cached OpenSubtitles lookup by `"<tmdb_id>\x01<lang3>"`.
get_opensubtitles,
/// Cache an OpenSubtitles lookup result (cid or `"null"` sentinel).
put_opensubtitles,
OPENSUBTITLES_TABLE
);
str_table_accessors!(get_preview_cid, put_preview_cid, PREVIEW_CID_TABLE);
str_table_accessors!(
/// Read a cached TMDB search result by lookup key.
get_tmdb_search,
/// Cache a TMDB search result under `key`.
put_tmdb_search,
TMDB_SEARCH_TABLE
);
str_table_accessors!(
/// Read cached TMDB TV-details JSON by `tmdbid`.
get_tmdb_tvdetails,
/// Cache TMDB TV-details JSON under `tmdbid`.
put_tmdb_tvdetails,
TMDB_TVDETAILS_TABLE
);
str_table_accessors!(
/// Read cached TMDB external-ids JSON by `tmdbid`.
get_tmdb_extids,
/// Cache TMDB external-ids JSON under `tmdbid`.
put_tmdb_extids,
TMDB_EXTIDS_TABLE
);
str_table_accessors!(
/// Read cached TMDB movie-details JSON by `tmdbid`.
get_tmdb_moviedetails,
/// Cache TMDB movie-details JSON under `tmdbid`.
put_tmdb_moviedetails,
TMDB_MOVIEDETAILS_TABLE
);
str_table_accessors!(
/// Read the cached ranked anchor list by normalized query key.
get_tmdb_principal_topn,
/// Cache the ranked anchor list under the normalized query key.
put_tmdb_principal_topn,
TMDB_PRINCIPAL_TOPN_TABLE
);
pub fn get_bibrec(
&self,
record_id: &str,
) -> Result<Option<std::collections::BTreeMap<String, String>>, redb::Error> {
let tx = self.db.begin_read()?;
let table = tx.open_table(BIBREC_TABLE)?;
let Some(v) = table.get(record_id)? else {
return Ok(None);
};
let s = v.value();
Ok(serde_json::from_str(s).ok())
}
pub fn put_bibrec(
&self,
record_id: &str,
fields: &std::collections::BTreeMap<String, String>,
) -> Result<(), redb::Error> {
let json = serde_json::to_string(fields).unwrap_or_else(|_| "{}".to_string());
let tx = self.db.begin_write()?;
{
let mut table = tx.open_table(BIBREC_TABLE)?;
table.insert(record_id, json.as_str())?;
}
tx.commit()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_cache_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
#[test]
fn put_then_get_returns_value() {
let dir = tmp_cache_dir();
let cache = MidhashCache::open(dir.path()).expect("open");
assert_eq!(cache.get_midhash("10.1038/x").unwrap(), None);
cache.put_midhash("10.1038/x", "bafyXYZ").expect("put");
assert_eq!(
cache.get_midhash("10.1038/x").unwrap().as_deref(),
Some("bafyXYZ")
);
}
#[test]
fn put_overwrites_existing_value() {
let dir = tmp_cache_dir();
let cache = MidhashCache::open(dir.path()).expect("open");
cache.put_midhash("k", "v1").unwrap();
cache.put_midhash("k", "v2").unwrap();
assert_eq!(cache.get_midhash("k").unwrap().as_deref(), Some("v2"));
}
#[test]
fn values_persist_across_reopens() {
let dir = tmp_cache_dir();
{
let cache = MidhashCache::open(dir.path()).expect("open 1");
cache.put_midhash("k", "persistent").unwrap();
}
let cache = MidhashCache::open(dir.path()).expect("open 2");
assert_eq!(
cache.get_midhash("k").unwrap().as_deref(),
Some("persistent")
);
}
#[test]
fn tmdb_search_cache_distinguishes_miss_from_absent() {
let dir = tmp_cache_dir();
let cache = MidhashCache::open(dir.path()).expect("open");
assert_eq!(cache.get_tmdb_search("m\u{1}naruto\u{1}").unwrap(), None);
cache.put_tmdb_search("m\u{1}naruto\u{1}", "null").unwrap();
assert_eq!(
cache
.get_tmdb_search("m\u{1}naruto\u{1}")
.unwrap()
.as_deref(),
Some("null")
);
cache
.put_tmdb_search("t\u{1}spy x family\u{1}2019", "{\"tmdbid\":120089}")
.unwrap();
drop(cache);
let cache = MidhashCache::open(dir.path()).expect("reopen");
assert_eq!(
cache
.get_tmdb_search("t\u{1}spy x family\u{1}2019")
.unwrap()
.as_deref(),
Some("{\"tmdbid\":120089}")
);
}
#[test]
fn tmdb_tvdetails_cache_roundtrips() {
let dir = tmp_cache_dir();
let cache = MidhashCache::open(dir.path()).expect("open");
assert_eq!(cache.get_tmdb_tvdetails("120089").unwrap(), None);
cache
.put_tmdb_tvdetails("120089", "{\"number_of_seasons\":2}")
.unwrap();
assert_eq!(
cache.get_tmdb_tvdetails("120089").unwrap().as_deref(),
Some("{\"number_of_seasons\":2}")
);
}
#[test]
fn entry_count_reflects_inserts() {
let dir = tmp_cache_dir();
let cache = MidhashCache::open(dir.path()).expect("open");
assert_eq!(cache.entry_count().unwrap(), 0);
cache.put_midhash("a", "x").unwrap();
cache.put_midhash("b", "y").unwrap();
assert_eq!(cache.entry_count().unwrap(), 2);
cache.put_midhash("a", "x2").unwrap();
assert_eq!(cache.entry_count().unwrap(), 2);
}
}
-288
View File
@@ -1,288 +0,0 @@
//! Shared helpers for single-outcome source plugins (`arxiv`, `gutenberg`,
//! `pubmed`, `wikicommons`, `giphy`, `scihub`).
//!
//! These plugins are structurally near-identical: build a reqwest client, map
//! HTTP status → [`GatewayError`], and front a per-record midhash cache whose
//! hit/put boilerplate is the same everywhere. Only the duplicated scaffolding
//! lives here; the genuinely-different bodies stay in the plugin modules.
use std::path::Path;
use std::time::Duration;
use tracing::{debug, warn};
use crate::cache::MidhashCache;
use crate::plugin::{ConfigError, HashKind, HashOutcome};
use crate::types::{DiscoveryRecord, GatewayError, Hash};
/// Build the shared reqwest client every source plugin uses.
pub fn build_http_client(
timeout_secs: u64,
user_agent: &str,
redirect: Option<reqwest::redirect::Policy>,
) -> reqwest::Client {
let mut builder = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.user_agent(user_agent);
if let Some(policy) = redirect {
builder = builder.redirect(policy);
}
builder
.build()
.expect("reqwest client build is infallible with rustls-tls")
}
/// Map a non-success HTTP status to a [`GatewayError`].
///
/// - `404` → [`GatewayError::NotFound`]
/// - `429` → [`GatewayError::RateLimited`] (honours `Retry-After`, default 60s)
/// - `5xx` → [`GatewayError::Transient`]
/// - anything else → [`GatewayError::Permanent`]
pub fn map_status(resp: &reqwest::Response) -> Result<(), GatewayError> {
let status = resp.status();
if status.is_success() {
return Ok(());
}
let code = status.as_u16();
if code == 404 {
return Err(GatewayError::NotFound);
}
if code == 429 {
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(60);
return Err(GatewayError::RateLimited {
retry_after_s: retry_after,
});
}
if status.is_server_error() {
return Err(GatewayError::Transient(format!(
"upstream {} returned {status}",
resp.url()
)));
}
Err(GatewayError::Permanent(format!(
"upstream {} returned {status}",
resp.url()
)))
}
/// Open the per-plugin redb midhash cache, wrapping the error with the plugin
/// name for a clean startup diagnostic.
pub fn open_midhash_cache(
cache_dir: &Path,
plugin: &'static str,
) -> Result<MidhashCache, ConfigError> {
MidhashCache::open(cache_dir).map_err(|e| ConfigError::Other {
plugin,
source: anyhow::anyhow!("open redb cache: {e}"),
})
}
/// Cache-hit fast path for single-outcome plugins.
///
/// On hit, returns `Some(vec![sparse outcome])` — hash only, no bytes/record —
/// so the core's `(_, None)` branch skips re-storing content. `Ok(None)` is a
/// cache miss; the caller proceeds to fetch + hash.
pub fn cached_outcome(
cache: &MidhashCache,
record_id: &str,
upstream_id: &'static str,
) -> Result<Option<Vec<HashOutcome>>, GatewayError> {
let Some(cached) = cache
.get_midhash(record_id)
.map_err(|e| GatewayError::Internal(anyhow::anyhow!("cache get: {e}")))?
else {
return Ok(None);
};
debug!(
target: "meta-share::gateway",
upstream = upstream_id,
record_id,
"hash cache hit"
);
Ok(Some(vec![HashOutcome {
hash: Hash(cached),
hash_kind: HashKind::Sha2_256,
bytes: None,
record: None,
file_extension: None,
}]))
}
/// Persist a freshly-computed `(record_id → cid)` mapping. A write failure is
/// non-fatal, so it is logged and swallowed.
pub fn store_midhash(cache: &MidhashCache, record_id: &str, upstream_id: &'static str, cid: &str) {
if let Err(e) = cache.put_midhash(record_id, cid) {
warn!(
target: "meta-share::gateway",
upstream = upstream_id,
record_id,
error = %e,
"hash cache put failed (non-fatal)"
);
}
}
/// Resolve the per-plugin cache handle, or produce the standard "not
/// configured" internal error.
pub fn require_cache<'a>(
cache: Option<&'a MidhashCache>,
plugin: &str,
) -> Result<&'a MidhashCache, GatewayError> {
cache.ok_or_else(|| {
GatewayError::Internal(anyhow::anyhow!(
"{plugin} plugin not configured (configure() never called)"
))
})
}
/// Build the single-element outcome vec a single-outcome plugin returns on a
/// fresh (cache-miss) compute: the full bytes + record, a Sha2_256 IPFS CID,
/// and an optional file extension.
pub fn single_outcome(
cid: String,
bytes: bytes::Bytes,
record: DiscoveryRecord,
file_extension: Option<String>,
) -> Vec<HashOutcome> {
vec![HashOutcome {
hash: Hash(cid),
hash_kind: HashKind::Sha2_256,
bytes: Some(bytes),
record: Some(record),
file_extension,
}]
}
/// Percent-encode a query-string value using the unreserved set
/// (RFC 3986 `A-Za-z0-9-_.~`) plus `+` for space.
pub fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match *b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
b' ' => out.push('+'),
other => out.push_str(&format!("%{:02X}", other)),
}
}
out
}
/// GET `url` and return the body bytes, mapping transport + status errors to
/// [`GatewayError`].
pub async fn fetch_bytes(http: &reqwest::Client, url: &str) -> Result<bytes::Bytes, GatewayError> {
let resp = http
.get(url)
.send()
.await
.map_err(|e| GatewayError::Transient(format!("GET {url}: {e}")))?;
map_status(&resp)?;
resp.bytes()
.await
.map_err(|e| GatewayError::Transient(format!("read body {url}: {e}")))
}
/// Like [`fetch_bytes`] but decodes the body as UTF-8 text.
pub async fn fetch_text(http: &reqwest::Client, url: &str) -> Result<String, GatewayError> {
let resp = http
.get(url)
.send()
.await
.map_err(|e| GatewayError::Transient(format!("GET {url}: {e}")))?;
map_status(&resp)?;
resp.text()
.await
.map_err(|e| GatewayError::Transient(format!("read body {url}: {e}")))
}
/// Decode a v1 BitTorrent infohash string into its raw 20 bytes.
///
/// Accepts 40 hex chars (case-insensitive) or 32 base32 chars (RFC 4648,
/// case-insensitive). Returns `None` for anything else.
pub fn decode_infohash(s: &str) -> Option<[u8; 20]> {
let bytes = s.as_bytes();
let raw: Vec<u8> = if bytes.len() == 40 && bytes.iter().all(u8::is_ascii_hexdigit) {
let mut out = Vec::with_capacity(20);
for pair in bytes.chunks(2) {
out.push((hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?);
}
out
} else if bytes.len() == 32 && bytes.iter().all(u8::is_ascii_alphanumeric) {
decode_base32_20(bytes)?
} else {
return None;
};
raw.try_into().ok()
}
fn hex_nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
/// Decode 32 RFC-4648 base32 chars (case-insensitive, no padding) into the 20
/// bytes they encode.
fn decode_base32_20(chars: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(20);
let mut buffer: u64 = 0;
let mut bits: u32 = 0;
for &c in chars {
let val = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a',
b'2'..=b'7' => c - b'2' + 26,
_ => return None,
} as u64;
buffer = (buffer << 5) | val;
bits += 5;
if bits >= 8 {
bits -= 8;
out.push((buffer >> bits) as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_infohash_hex_roundtrips() {
let hex = "0123456789abcdef0123456789abcdef01234567";
let raw = decode_infohash(hex).expect("hex decodes");
assert_eq!(raw[0], 0x01);
assert_eq!(raw[19], 0x67);
}
#[test]
fn decode_infohash_is_case_insensitive_hex() {
let lower = decode_infohash("aabbccddeeff00112233445566778899aabbccdd");
let upper = decode_infohash("AABBCCDDEEFF00112233445566778899AABBCCDD");
assert!(lower.is_some());
assert_eq!(lower, upper);
}
#[test]
fn decode_infohash_accepts_base32() {
let raw = decode_infohash("MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U");
assert!(raw.is_some());
}
#[test]
fn decode_infohash_rejects_wrong_shape() {
assert!(decode_infohash("tooshort").is_none());
assert!(decode_infohash("zz23456789abcdef0123456789abcdef01234567").is_none());
assert!(decode_infohash("").is_none());
}
}
-312
View File
@@ -1,312 +0,0 @@
//! Self-describing per-plugin configuration: schema, redaction, merge, and the
//! generic schema-driven HTML page the feeder serves at `/config`.
//!
//! The point of this module is that **the gateway carries no per-plugin config
//! knowledge** and **the SDK carries no per-plugin field names**. A plugin
//! declares its fields via [`FeederPlugin::config_schema`](crate::FeederPlugin::config_schema);
//! everything here (redaction of secrets, merge-on-save, and the rendered form)
//! is driven entirely by that schema. Add a feeder → it shows up with a working
//! config form and zero UI/gateway changes.
//!
//! ## Persistence & precedence
//!
//! Config lives at `cache_dir/config.json` (the per-plugin dir handed to
//! `configure()`). The plugin reads **file-or-env** at startup: env is the
//! first-boot seed and stays authoritative until the operator saves through the
//! UI, at which point `config.json` exists and wins. There is no runtime reload
//! — a save returns `restart_required: true` (mirrors the gateway's
//! `gateway-config.json` posture).
//!
//! ## Secrets
//!
//! Fields of kind [`FieldKind::Secret`] are **write-only**: never returned to
//! the page (redacted to a `<key>_set: bool`), and a blank incoming value on
//! save means "keep the stored secret".
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A plugin's full config surface — an ordered list of fields.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ConfigSchema {
pub fields: Vec<ConfigField>,
}
impl ConfigSchema {
/// Convenience: `true` when the plugin declares no configurable fields (the
/// page then renders a "no configuration required" notice).
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
}
/// One configurable field. `record_array` fields nest `fields` for the
/// per-record sub-schema (e.g. torznab's `indexers`).
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ConfigField {
/// JSON key in the config object.
pub key: String,
/// Human label for the form.
pub label: String,
#[serde(rename = "type")]
pub kind: FieldKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
#[serde(default)]
pub required: bool,
/// Sub-fields for [`FieldKind::RecordArray`]. The FIRST sub-field is treated
/// as the record identifier for secret back-fill on merge (e.g. `name`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<Vec<ConfigField>>,
}
impl ConfigField {
pub fn text(key: &str, label: &str) -> Self {
Self::new(key, label, FieldKind::Text)
}
pub fn secret(key: &str, label: &str) -> Self {
Self::new(key, label, FieldKind::Secret)
}
pub fn bool(key: &str, label: &str) -> Self {
Self::new(key, label, FieldKind::Bool)
}
pub fn number(key: &str, label: &str) -> Self {
Self::new(key, label, FieldKind::Number)
}
pub fn list(key: &str, label: &str) -> Self {
Self::new(key, label, FieldKind::List)
}
pub fn record_array(key: &str, label: &str, fields: Vec<ConfigField>) -> Self {
let mut f = Self::new(key, label, FieldKind::RecordArray);
f.fields = Some(fields);
f
}
fn new(key: &str, label: &str, kind: FieldKind) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
kind,
help: None,
required: false,
fields: None,
}
}
pub fn with_help(mut self, help: &str) -> Self {
self.help = Some(help.to_string());
self
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
}
/// Field widget / value type. Drives both rendering and the secret/merge rules.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
/// Free text → JSON string.
Text,
/// Write-only secret → JSON string; redacted on read, "blank keeps" on save.
Secret,
/// Checkbox → JSON bool.
Bool,
/// Numeric input → JSON number.
Number,
/// Comma/line list → JSON array of strings.
List,
/// Repeatable record → JSON array of objects keyed by nested `fields`.
RecordArray,
}
fn is_blank(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Null) => true,
Some(Value::String(s)) => s.is_empty(),
_ => false,
}
}
/// Produce a UI-safe view of `values`: secret fields are dropped and replaced
/// with a `<key>_set` boolean; everything else passes through. Recurses into
/// `record_array` records for secret sub-fields.
pub fn redact(values: &Value, schema: &ConfigSchema) -> Value {
let src = values.as_object().cloned().unwrap_or_default();
Value::Object(redact_object(&src, &schema.fields))
}
fn redact_object(src: &Map<String, Value>, fields: &[ConfigField]) -> Map<String, Value> {
let mut out = Map::new();
for f in fields {
match f.kind {
FieldKind::Secret => {
let set = !is_blank(src.get(&f.key));
out.insert(format!("{}_set", f.key), Value::Bool(set));
}
FieldKind::RecordArray => {
let subs = f.fields.as_deref().unwrap_or(&[]);
let arr = src
.get(&f.key)
.and_then(Value::as_array)
.map(|recs| {
recs.iter()
.map(|r| {
let m = r.as_object().cloned().unwrap_or_default();
Value::Object(redact_object(&m, subs))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
out.insert(f.key.clone(), Value::Array(arr));
}
_ => {
if let Some(v) = src.get(&f.key) {
out.insert(f.key.clone(), v.clone());
}
}
}
}
out
}
/// Merge `incoming` (from the UI) onto `base` (the stored/effective config),
/// schema-aware. Returns the full, UNREDACTED object to persist:
/// - secret: blank/absent incoming keeps `base`; non-blank overwrites.
/// - text/number/bool/list: present in incoming overwrites; absent keeps base.
/// - record_array: incoming array replaces base, but blank secret sub-fields are
/// back-filled from the base record matched by the first sub-field (identifier).
pub fn merge(base: &Value, incoming: &Value, schema: &ConfigSchema) -> Value {
let base_obj = base.as_object().cloned().unwrap_or_default();
let inc_obj = incoming.as_object().cloned().unwrap_or_default();
Value::Object(merge_object(&base_obj, &inc_obj, &schema.fields))
}
fn merge_object(
base: &Map<String, Value>,
inc: &Map<String, Value>,
fields: &[ConfigField],
) -> Map<String, Value> {
let mut out = base.clone();
for f in fields {
match f.kind {
FieldKind::Secret => {
if !is_blank(inc.get(&f.key)) {
out.insert(f.key.clone(), inc.get(&f.key).cloned().unwrap());
}
// blank/absent → keep base (no-op)
}
FieldKind::RecordArray => {
let subs = f.fields.as_deref().unwrap_or(&[]);
let id_key = subs.first().map(|s| s.key.clone());
let base_recs = base.get(&f.key).and_then(Value::as_array).cloned();
let inc_recs = match inc.get(&f.key).and_then(Value::as_array) {
Some(r) => r.clone(),
None => continue, // absent → keep base array untouched
};
let merged: Vec<Value> = inc_recs
.iter()
.map(|rec| {
let rec_obj = rec.as_object().cloned().unwrap_or_default();
// Find the matching base record by identifier sub-field.
let base_match = match (&id_key, base_recs.as_ref()) {
(Some(idk), Some(brs)) => brs
.iter()
.find(|br| br.get(idk) == rec_obj.get(idk))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default(),
_ => Map::new(),
};
Value::Object(merge_object(&base_match, &rec_obj, subs))
})
.collect();
out.insert(f.key.clone(), Value::Array(merged));
}
_ => {
if let Some(v) = inc.get(&f.key) {
out.insert(f.key.clone(), v.clone());
}
}
}
}
out
}
/// The generic, schema-driven config page served at `GET /config`. Self-contained
/// (inline CSS + JS, no external assets) so it loads identically when hit
/// directly on the feeder OR reverse-proxied through the gateway. All API calls
/// are relative to `location.pathname`, so the proxy mount prefix is irrelevant.
pub const CONFIG_PAGE_HTML: &str = include_str!("config_page.html");
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn torznab_like() -> ConfigSchema {
ConfigSchema {
fields: vec![
ConfigField::secret("tmdb_token", "TMDB token"),
ConfigField::bool("resolve_torrent_files", "Resolve .torrent"),
ConfigField::record_array(
"indexers",
"Indexers",
vec![
ConfigField::text("name", "Name"),
ConfigField::secret("api_key", "API key"),
],
),
],
}
}
#[test]
fn redact_hides_secrets_and_flags_set() {
let v = json!({
"tmdb_token": "abc",
"resolve_torrent_files": true,
"indexers": [{"name":"prowlarr","api_key":"k"}]
});
let r = redact(&v, &torznab_like());
assert_eq!(r["tmdb_token_set"], json!(true));
assert!(r.get("tmdb_token").is_none());
assert_eq!(r["resolve_torrent_files"], json!(true));
assert_eq!(r["indexers"][0]["name"], json!("prowlarr"));
assert_eq!(r["indexers"][0]["api_key_set"], json!(true));
assert!(r["indexers"][0].get("api_key").is_none());
}
#[test]
fn merge_blank_secret_keeps_base() {
let base = json!({"tmdb_token": "keepme"});
let inc = json!({"tmdb_token": ""});
let m = merge(&base, &inc, &torznab_like());
assert_eq!(m["tmdb_token"], json!("keepme"));
}
#[test]
fn merge_nonblank_secret_overwrites() {
let base = json!({"tmdb_token": "old"});
let inc = json!({"tmdb_token": "new"});
let m = merge(&base, &inc, &torznab_like());
assert_eq!(m["tmdb_token"], json!("new"));
}
#[test]
fn merge_record_array_backfills_blank_secret_by_identifier() {
let base = json!({"indexers":[{"name":"prowlarr","api_key":"secret"}]});
// UI re-submits the indexer with a blank api_key → keep the stored one.
let inc = json!({"indexers":[{"name":"prowlarr","api_key":""}]});
let m = merge(&base, &inc, &torznab_like());
assert_eq!(m["indexers"][0]["api_key"], json!("secret"));
assert_eq!(m["indexers"][0]["name"], json!("prowlarr"));
}
#[test]
fn merge_bool_overwrites_when_present() {
let base = json!({"resolve_torrent_files": true});
let inc = json!({"resolve_torrent_files": false});
let m = merge(&base, &inc, &torznab_like());
assert_eq!(m["resolve_torrent_files"], json!(false));
}
}
-268
View File
@@ -1,268 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>feeder config</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 18px;
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0d0d12; color: #e6e6ea;
}
h1 { font-size: 16px; margin: 0 0 4px; }
.sub { color: #9a9aa6; font-size: 12px; margin: 0 0 16px; }
.field { margin: 0 0 16px; }
.field > label { display: block; font-weight: 600; margin: 0 0 4px; }
.help { color: #8a8a96; font-size: 12px; margin: 3px 0 0; }
.req { color: #f0a0a0; margin-left: 3px; font-weight: 700; }
input[type=text], input[type=password], input[type=number], textarea {
width: 100%; padding: 8px 10px; border-radius: 8px;
border: 1px solid #2a2a35; background: #15151c; color: #e6e6ea; font: inherit;
}
textarea { min-height: 56px; resize: vertical; }
input[type=checkbox] { width: 16px; height: 16px; vertical-align: middle; }
.pill { font-size: 10px; padding: 2px 7px; border-radius: 999px; margin-left: 8px; vertical-align: middle; }
.pill.set { background: #14361f; color: #6ee7a0; border: 1px solid #1f5132; }
.pill.unset { background: #2a2a35; color: #b9b9c4; border: 1px solid #3a3a45; }
.record { border: 1px solid #2a2a35; border-radius: 10px; padding: 12px; margin: 0 0 10px; background: #111118; }
.record .field { margin-bottom: 10px; }
.row-actions { text-align: right; }
button {
font: inherit; cursor: pointer; border-radius: 8px; padding: 7px 14px;
border: 1px solid #3a3a45; background: #1c1c26; color: #e6e6ea;
}
button.primary { background: #6d4aff; border-color: #6d4aff; color: #fff; font-weight: 600; }
button.danger { background: transparent; border-color: #5a2a2a; color: #e29; padding: 4px 10px; }
button.add { margin-top: 4px; }
.bar { position: sticky; bottom: 0; padding: 12px 0 2px; background: linear-gradient(#0d0d1200, #0d0d12 30%); }
.banner { padding: 9px 12px; border-radius: 8px; margin: 0 0 14px; font-size: 13px; display: none; }
.banner.ok { background: #14361f; color: #9ff0bf; border: 1px solid #1f5132; display: block; }
.banner.warn { background: #3a2f12; color: #f0d79f; border: 1px solid #5a4a1f; display: block; }
.banner.err { background: #3a1414; color: #f0a0a0; border: 1px solid #5a2020; display: block; }
.empty { color: #9a9aa6; }
</style>
</head>
<body>
<h1 id="title">Plugin configuration</h1>
<p class="sub">Secrets are write-only — set values are shown as “set”, never echoed back. Saving requires a feeder restart to take effect. Fields marked <span class="req">*</span> are required.</p>
<div id="banner" class="banner"></div>
<form id="form"></form>
<div class="bar" id="bar" style="display:none">
<button type="button" class="primary" id="save">Save</button>
</div>
<script>
// All API calls are relative to this page's path, so the same page works when
// served directly on the feeder (/config) or reverse-proxied through the
// gateway (/api/gateway/plugins/<id>/ui/config).
const BASE = location.pathname.replace(/\/+$/, "");
let SCHEMA = { fields: [] };
let VALUES = {};
const $ = (id) => document.getElementById(id);
function banner(kind, msg) {
const b = $("banner");
b.className = "banner " + kind;
b.textContent = msg;
}
function el(tag, attrs, ...kids) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === "class") e.className = v;
else if (k === "text") e.textContent = v;
else if (k in e) e[k] = v;
else e.setAttribute(k, v);
}
for (const c of kids) if (c != null) e.append(c);
return e;
}
// Split help text into plain runs + clickable <a> nodes for any http(s) URL.
// Built as DOM nodes (never innerHTML) so help stays XSS-safe; trailing
// punctuation is kept outside the link so "see http://host/ui." reads right.
function linkify(text) {
const out = [];
const re = /https?:\/\/[^\s]+/g;
let last = 0, m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
let url = m[0], trail = "";
const t = url.match(/[.,;:)\]]+$/);
if (t) { trail = t[0]; url = url.slice(0, -trail.length); }
out.push(el("a", { href: url, target: "_blank", rel: "noopener noreferrer", text: url }));
if (trail) out.push(trail);
last = m.index + m[0].length;
}
if (last < text.length) out.push(text.slice(last));
return out;
}
function helpEl(f) { return f.help ? el("p", { class: "help" }, ...linkify(f.help)) : null; }
// A "*" marker for required fields (null for optional ones).
function reqStar(f) { return f.required ? el("span", { class: "req", text: "*", title: "required" }) : null; }
// Render one scalar field. `vals` is the object holding the value/`_set` flag.
// Every returned widget exposes `_collect()` and `_key` so collect() can read it.
function fieldInput(f, vals) {
if (f.type === "bool") {
const cb = el("input", { type: "checkbox" });
cb.checked = vals[f.key] === true;
const w = el("div", { class: "field" },
el("label", {}, cb, " ", el("span", { text: " " + f.label }), reqStar(f)),
helpEl(f));
w._collect = () => cb.checked;
w._key = f.key;
return w;
}
const label = el("label", { text: f.label });
const star = reqStar(f); if (star) label.append(star);
let input;
if (f.type === "secret") {
const isSet = vals[f.key + "_set"] === true;
input = el("input", { type: "password", placeholder: isSet ? "•••••••• (blank keeps current)" : "not set" });
label.append(el("span", { class: "pill " + (isSet ? "set" : "unset"), text: isSet ? "set" : "not set" }));
input._get = () => input.value; // blank => merge keeps stored secret
} else if (f.type === "number") {
input = el("input", { type: "number" });
if (vals[f.key] != null) input.value = vals[f.key];
input._get = () => (input.value === "" ? null : Number(input.value));
} else if (f.type === "list") {
input = el("textarea", { placeholder: "one per line or comma-separated" });
const arr = Array.isArray(vals[f.key]) ? vals[f.key] : [];
input.value = arr.join("\n");
input._get = () => input.value.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
} else {
input = el("input", { type: "text" });
if (vals[f.key] != null) input.value = vals[f.key];
input._get = () => input.value;
}
const wrap = el("div", { class: "field" }, label, input, helpEl(f));
wrap._collect = () => input._get();
wrap._key = f.key;
return wrap;
}
// Render a record_array (repeatable group). Returns a wrapper exposing _collect.
function recordArray(f, vals) {
const subs = f.fields || [];
const idKey = subs.length ? subs[0].key : null;
const list = el("div", {});
const records = Array.isArray(vals[f.key]) ? vals[f.key].slice() : [];
function addRecord(recVals) {
const card = el("div", { class: "record" });
const inputs = subs.map((sf) => {
const fi = fieldInput(sf, recVals || {});
card.append(fi);
return fi;
});
const rm = el("button", { type: "button", class: "danger", text: "remove" });
rm.onclick = () => card.remove();
card.append(el("div", { class: "row-actions" }, rm));
card._collect = () => {
const o = {};
for (const fi of inputs) o[fi._key] = fi._collect();
return o;
};
list.append(card);
return card;
}
records.forEach((r) => addRecord(r));
const add = el("button", { type: "button", class: "add", text: "+ add " + f.label.toLowerCase().replace(/s$/, "") });
add.onclick = () => addRecord({});
const lbl = el("label", { text: f.label });
const star = reqStar(f); if (star) lbl.append(star);
const wrap = el("div", { class: "field" }, lbl, helpEl(f), list, add);
wrap._key = f.key;
wrap._collect = () => Array.from(list.children).map((c) => c._collect());
return wrap;
}
function render() {
$("title").textContent = (SCHEMA.title || "Plugin") + " configuration";
const form = $("form");
form.innerHTML = "";
if (!SCHEMA.fields || !SCHEMA.fields.length) {
form.append(el("p", { class: "empty", text: "No configuration required for this plugin." }));
$("bar").style.display = "none";
return;
}
const widgets = [];
for (const f of SCHEMA.fields) {
const w = f.type === "record_array" ? recordArray(f, VALUES) : fieldInput(f, VALUES);
widgets.push(w);
form.append(w);
}
form._widgets = widgets;
$("bar").style.display = "block";
}
function collect() {
const out = {};
for (const w of $("form")._widgets || []) out[w._key] = w._collect();
return out;
}
async function save() {
$("save").disabled = true;
try {
const res = await fetch(BASE + "/values", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(collect()),
});
if (!res.ok) {
const detail = (await res.text().catch(() => "")).trim();
throw new Error(detail ? `the feeder rejected it (HTTP ${res.status}): ${detail}`
: `the feeder returned HTTP ${res.status}`);
}
const j = await res.json().catch(() => ({}));
if (j.reloading) {
// The feeder is restarting itself (~2-3s) to apply the change. Poll it
// back up, then refresh the form so secret pills reflect stored state.
banner("warn", "Saved — reloading the feeder to apply (~3 s)…");
let tries = 6;
const poll = async () => {
try { await load(); banner("ok", "Reloaded — new config applied."); }
catch { if (--tries > 0) setTimeout(poll, 1500); else banner("warn", "Saved & reloading — reopen this panel shortly to confirm."); }
};
setTimeout(poll, 3000);
} else {
if (j.restart_required) banner("warn", "Saved. Restart the feeder container for changes to take effect.");
else banner("ok", "Saved.");
// Re-fetch so secret pills reflect the new stored state.
await load();
}
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
const friendly = /Failed to fetch|NetworkError|load failed/i.test(msg)
? "couldn't reach the feeder — it may be restarting; wait a moment and try again."
: msg;
banner("err", "Could not save: " + friendly);
} finally {
$("save").disabled = false;
}
}
async function load() {
const [s, v] = await Promise.all([
fetch(BASE + "/schema").then((r) => r.json()),
fetch(BASE + "/values").then((r) => r.json()),
]);
SCHEMA = s || { fields: [] };
VALUES = v || {};
render();
}
$("save").onclick = save;
load().catch((e) => banner("err", "Failed to load config: " + e.message));
</script>
</body>
</html>
-614
View File
@@ -1,614 +0,0 @@
//! Feeder-driven enrichment — the "smart feeder / dumb gateway" pipeline.
//!
//! A feeder that self-publishes (has a `META_CORE_URL`) writes its own base
//! record, then drives the **reused meta-sort enrichment plugins**
//! (`metamesh-plugin-filename-parser`, `metamesh-plugin-tmdb`) over HTTP. Each
//! plugin is handed the `metaCoreUrl` and writes its own fields straight back to
//! meta-core (`PATCH /meta/{cid}` merge), so the gateway never touches
//! enrichment — full separation of concerns.
//!
//! ## Ordering — why we poll
//!
//! tmdb searches on `originalTitle`/`movieYear`/`videoType` (which it reads from
//! the record), and those are produced by filename-parser. So the pipeline is
//! **filename-parser → tmdb**. The plugin `/process` contract is *async*
//! (returns `accepted`, then writes to meta-core and POSTs a callback), so after
//! kicking filename-parser we **poll meta-core** until `originalTitle` appears
//! (bounded), read the merged fields back, and hand them to tmdb. Polling keeps
//! the feeder stateless — no callback-correlation bookkeeping. The plugins'
//! callback POSTs still fire; they hit the feeder's no-op `/enrich/callback`.
//!
//! The whole pipeline runs in a spawned background task so it never adds latency
//! to the libp2p resolve path; the meta-share ingester propagates the enriched
//! record on its next poll (~eventually consistent).
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use tracing::{debug, warn};
use crate::meta_core::{self, Provenance};
use crate::types::DiscoveryRecord;
/// Operator-supplied enrichment wiring, parsed once from the feeder env.
#[derive(Clone, Debug)]
pub struct EnrichmentConfig {
/// meta-core root the feeder self-publishes to + hands to the plugins.
pub meta_core_url: String,
/// `metamesh-plugin-filename-parser` base URL (optional).
pub filename_parser_url: Option<String>,
/// `metamesh-plugin-tmdb` base URL (optional).
pub tmdb_url: Option<String>,
/// TMDB v3 key / v4 bearer token, POSTed to tmdb `/configure`.
pub tmdb_token: Option<String>,
/// TMDB metadata language (default `en-US`).
pub tmdb_language: String,
/// Stable per-deployment id stamped into `provenance.gatewayPeer`.
pub gateway_peer: String,
/// URL the plugins POST their completion callback to (the feeder's own
/// no-op `/enrich/callback`). Polling is the real completion signal; this
/// only keeps the plugins from logging a failed-callback warning.
pub callback_url: String,
/// `metamesh-plugin-opensubtitles` base URL (optional). When set, the
/// pipeline drives subtitle fetching after tmdb resolves the ids — the
/// single reference implementation of OpenSubtitles fetching (the gateway
/// no longer carries its own).
pub opensubtitles_url: Option<String>,
/// OpenSubtitles consumer API key, POSTed to the plugin's `/configure`.
pub opensubtitles_api_key: Option<String>,
/// OpenSubtitles account username (required by the plugin for downloads).
pub opensubtitles_username: Option<String>,
/// OpenSubtitles account password.
pub opensubtitles_password: Option<String>,
/// Wanted subtitle languages (CSV of ISO 639-1 codes; default `en`).
pub opensubtitles_languages: String,
}
fn env_nonempty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Derive a co-bundled sidecar plugin URL from the feeder's own hostname, by the
/// app-compose convention `<prefix>-feeder` → `<prefix>-<plugin>:8080`
/// (e.g. `metafeedertorznab-feeder` → `http://metafeedertorznab-tmdb:8080`). Lets
/// a feeder reach its bundled filename-parser / tmdb sidecars with **no explicit
/// `*_PLUGIN_URL` env** — config lives in the UI, wiring is derived. Returns
/// `None` when `HOSTNAME` is unset or not in `<prefix>-feeder` form, in which case
/// the caller falls back to the env seed (then soft-skips that plugin).
fn derive_sidecar_url(plugin: &str) -> Option<String> {
let host = env_nonempty("HOSTNAME")?;
let prefix = host.strip_suffix("-feeder")?;
Some(format!("http://{prefix}-{plugin}:8080"))
}
impl EnrichmentConfig {
/// Parse from the feeder env. Returns `None` when `META_CORE_URL` is unset —
/// the **bundled-feeder** path (records returned over HTTP, the gateway
/// dispatcher stores them). Self-publishing feeders that source meta-core from
/// their own dashboard config use [`from_meta_core`](Self::from_meta_core).
pub fn from_env() -> Option<Self> {
Some(Self::from_meta_core(env_nonempty("META_CORE_URL")?))
}
/// Build with an explicit meta-core root — sourced from the feeder's own
/// `config.json` (dashboard) when set, else the `META_CORE_URL` env seed. The
/// sidecar URLs come from their `*_PLUGIN_URL` env, falling back to the
/// hostname-derived co-bundled sidecar (see [`derive_sidecar_url`]); the rest
/// keep their env seeds / defaults. Secrets (tmdb token, language) are
/// overlaid by the caller from the per-feeder config.
pub fn from_meta_core(meta_core_url: String) -> Self {
EnrichmentConfig {
meta_core_url,
filename_parser_url: env_nonempty("FILENAME_PARSER_URL")
.or_else(|| derive_sidecar_url("filename-parser")),
tmdb_url: env_nonempty("TMDB_PLUGIN_URL").or_else(|| derive_sidecar_url("tmdb")),
tmdb_token: env_nonempty("TMDB_TOKEN").or_else(|| env_nonempty("TORZNAB_TMDB_TOKEN")),
tmdb_language: env_nonempty("TMDB_LANGUAGE").unwrap_or_else(|| "en-US".to_string()),
gateway_peer: env_nonempty("META_GATEWAY_PEER_ID")
.or_else(|| env_nonempty("HOSTNAME"))
.unwrap_or_else(|| "gateway-feeder".to_string()),
callback_url: env_nonempty("META_FEEDER_CALLBACK_URL")
.unwrap_or_else(|| "http://127.0.0.1:8080/enrich/callback".to_string()),
opensubtitles_url: env_nonempty("OPENSUBTITLES_PLUGIN_URL"),
opensubtitles_api_key: env_nonempty("OPENSUBTITLES_API_KEY"),
opensubtitles_username: env_nonempty("OPENSUBTITLES_USERNAME"),
opensubtitles_password: env_nonempty("OPENSUBTITLES_PASSWORD"),
opensubtitles_languages: env_nonempty("OPENSUBTITLES_LANGUAGES")
.unwrap_or_else(|| "en".to_string()),
}
}
}
/// How long to wait for filename-parser to merge `originalTitle` before handing
/// off to tmdb. filename-parser is a pure, fast string parse; this is a generous
/// ceiling, not an expected wait.
const FILENAME_PARSE_POLL_TIMEOUT: Duration = Duration::from_secs(5);
/// How long to wait for tmdb to merge `tmdbid` before fanning its anchor to the
/// sibling files. tmdb hits the network, so a longer ceiling than filename.
const TMDB_POLL_TIMEOUT: Duration = Duration::from_secs(20);
const POLL_INTERVAL: Duration = Duration::from_millis(200);
/// Show-level TMDB fields that are **fanned** from the primary file to every
/// sibling file in a multi-file (season-pack) torrent — they're identical for
/// every episode of a show, so we resolve tmdb **once** and copy these across
/// (avoids N TMDB lookups; the tmdb plugin caches by `cid_midhash256`, which
/// gateway BT records don't carry, so without this each file would re-hit TMDB).
/// Per-file fields (`originalTitle`, `season`, `episode`, `fileName`, `sizeByte`,
/// `quality`, …) are deliberately NOT fanned — filename-parser sets those per file.
const ANCHOR_EXACT: &[&str] = &[
"tmdbid",
"imdbid",
"poster",
"backdrop",
"rating",
"releasedate",
"studio",
"genres",
"tags",
];
const ANCHOR_PREFIXES: &[&str] = &["plot/", "genres/", "studio/", "tags/"];
/// One file (or pack) to enrich: its CID, the filename to parse, and the base
/// fields already written to meta-core (handed to the plugins as `existingMeta`).
#[derive(Clone, Debug)]
pub struct EnrichTarget {
pub cid: String,
pub file_name: String,
pub base: BTreeMap<String, String>,
}
/// Drives meta-core writes + the enrichment plugins for one feeder. Cheap to
/// clone (just a reqwest client + an `Arc<config>`), so it can be moved into a
/// spawned per-resolve task.
#[derive(Clone)]
pub struct Enricher {
http: reqwest::Client,
cfg: Arc<EnrichmentConfig>,
}
#[derive(Serialize)]
struct ProcessRequest<'a> {
#[serde(rename = "taskId")]
task_id: String,
cid: &'a str,
#[serde(rename = "filePath")]
file_path: &'a str,
#[serde(rename = "callbackUrl")]
callback_url: &'a str,
#[serde(rename = "metaCoreUrl")]
meta_core_url: &'a str,
#[serde(rename = "existingMeta")]
existing_meta: &'a BTreeMap<String, String>,
}
impl Enricher {
pub fn new(http: reqwest::Client, cfg: EnrichmentConfig) -> Self {
Enricher {
http,
cfg: Arc::new(cfg),
}
}
pub fn meta_core_url(&self) -> &str {
&self.cfg.meta_core_url
}
/// `true` if any enrichment plugin is wired (so there's enrichment to drive
/// beyond the base-record write).
pub fn has_plugins(&self) -> bool {
self.cfg.filename_parser_url.is_some() || self.cfg.tmdb_url.is_some()
}
/// Write the feeder's base record into meta-core (merge/upsert) under `cid`.
/// Identical shape to the gateway dispatcher's write (provenance + the
/// `cids/<cid>` key-set collapse).
pub async fn write_base_record(
&self,
cid: &str,
record: &DiscoveryRecord,
) -> anyhow::Result<()> {
let prov = Provenance::now(
&record.upstream_id,
&record.record_id,
&self.cfg.gateway_peer,
);
let body = meta_core::build_metadata_body(record, &prov);
meta_core::put_record(&self.http, &self.cfg.meta_core_url, cid, &body).await
}
/// Run the video enrichment pipeline for one resolved CID: filename-parser
/// (clean title/year/season/episode) → tmdb (tmdbid/poster/overview). Each
/// plugin writes its own fields to meta-core. Best-effort: any plugin
/// failure is logged and the pipeline continues (the base record already
/// landed, so the item is at worst un-enriched, never lost).
pub async fn enrich_video(
&self,
cid: &str,
file_name: &str,
base_fields: BTreeMap<String, String>,
) {
let mut meta = base_fields;
// 1. filename-parser → originalTitle / movieYear / videoType / season / episode.
if let Some(fp_url) = self.cfg.filename_parser_url.clone() {
match self.process(&fp_url, "filename-parser", cid, file_name, &meta).await {
Ok(()) => {
// Poll for the merged fields so tmdb gets the clean title.
if let Some(merged) = self
.poll_until_field(cid, "originalTitle", FILENAME_PARSE_POLL_TIMEOUT)
.await
{
meta = merged;
} else {
debug!(
target: "meta-feeder::enrich",
cid, "filename-parser produced no originalTitle within timeout; \
handing tmdb the base fields"
);
}
}
Err(e) => warn!(
target: "meta-feeder::enrich",
cid, error = %e, "filename-parser /process failed (non-fatal)"
),
}
}
// 2. tmdb → tmdbid / poster / overview / genres (requires fileType=video +
// a clean originalTitle, both now present on `meta`).
if let Some(tmdb_url) = self.cfg.tmdb_url.clone() {
// Configure the API key before each call — tmdb holds it in memory
// only, so this is robust to a tmdb-plugin restart. Cheap (local).
if let Err(e) = self.configure_tmdb(&tmdb_url).await {
warn!(
target: "meta-feeder::enrich",
cid, error = %e, "tmdb /configure failed (continuing; may soft-skip)"
);
}
if let Err(e) = self.process(&tmdb_url, "tmdb", cid, file_name, &meta).await {
warn!(
target: "meta-feeder::enrich",
cid, error = %e, "tmdb /process failed (non-fatal)"
);
}
}
// 3. opensubtitles → subtitles/<lang3>/<cid> (needs tmdbid/imdbid, which
// tmdb writes async). Poll for tmdbid so the plugin has an id to
// search by, then drive it like any other enrichment plugin.
if self.cfg.opensubtitles_url.is_some() {
let merged = self
.poll_until_field(cid, "tmdbid", TMDB_POLL_TIMEOUT)
.await
.unwrap_or(meta);
self.enrich_subtitles(cid, file_name, &merged).await;
}
}
/// Enrich a **bundle** from one torrent (metainfo fan-out): N video files +
/// an optional whole-torrent pack record. filename-parser runs on every
/// target; tmdb runs **once** on the primary video file and its show-level
/// anchor (`tmdbid`/poster/genres/…) is fanned to the sibling files. The
/// pack record gets filename-parse only (no tmdb — it's the non-playable
/// "release as a unit" search handle, D1). Best-effort throughout.
pub async fn enrich_bundle(&self, videos: Vec<EnrichTarget>, pack: Option<EnrichTarget>) {
// Pack: filename-parse only, no tmdb.
if let Some(p) = pack {
self.fire_filename(&p).await;
}
if videos.is_empty() {
return;
}
if videos.len() == 1 {
// Degenerate single-video bundle → the plain pipeline (filename → tmdb).
let v = &videos[0];
self.enrich_video(&v.cid, &v.file_name, v.base.clone()).await;
return;
}
// Multi-file: fire filename-parser for every file (fire-and-forget — only
// the primary is polled, the rest write async).
for v in &videos {
self.fire_filename(v).await;
}
// Primary = largest by `sizeByte` (ties → lowest index, i.e. first seen).
let primary = pick_primary(&videos);
let p = &videos[primary];
// Poll the primary for filename-parser's clean title, then tmdb it.
let merged = self
.poll_until_field(&p.cid, "originalTitle", FILENAME_PARSE_POLL_TIMEOUT)
.await
.unwrap_or_else(|| p.base.clone());
let Some(tmdb_url) = self.cfg.tmdb_url.clone() else {
return; // no tmdb wired → filename-only enrichment, done.
};
if let Err(e) = self.configure_tmdb(&tmdb_url).await {
warn!(target: "meta-feeder::enrich", cid = %p.cid, error = %e, "tmdb /configure failed");
}
if let Err(e) = self.process(&tmdb_url, "tmdb", &p.cid, &p.file_name, &merged).await {
warn!(target: "meta-feeder::enrich", cid = %p.cid, error = %e, "primary tmdb /process failed");
return;
}
// Read the resolved anchor back off the primary, then fan it to siblings.
let Some(anchor_rec) = self.poll_until_field(&p.cid, "tmdbid", TMDB_POLL_TIMEOUT).await
else {
debug!(target: "meta-feeder::enrich", cid = %p.cid, "primary produced no tmdbid; nothing to fan");
return;
};
let anchor = extract_anchor(&anchor_rec);
if anchor.is_empty() {
return;
}
for (i, v) in videos.iter().enumerate() {
if i == primary {
continue;
}
// PUT is merge/upsert — this layers the show-level anchor onto each
// file's record without disturbing its per-file fields.
if let Err(e) =
meta_core::put_record(&self.http, &self.cfg.meta_core_url, &v.cid, &anchor).await
{
warn!(target: "meta-feeder::enrich", cid = %v.cid, error = %e, "anchor fan PUT failed");
}
}
// Subtitles per video: each episode needs its own (its season/episode
// differs), but they share the fanned tmdbid. Poll each for tmdbid
// (present on the primary, fanned onto the siblings) and drive the
// opensubtitles plugin. Best-effort + quota-guarded inside the plugin.
if self.cfg.opensubtitles_url.is_some() {
for v in &videos {
let merged = self
.poll_until_field(&v.cid, "tmdbid", TMDB_POLL_TIMEOUT)
.await
.unwrap_or_else(|| v.base.clone());
self.enrich_subtitles(&v.cid, &v.file_name, &merged).await;
}
}
}
/// Fire filename-parser `/process` for one target (fire-and-forget; the
/// plugin merges its fields to meta-core async).
async fn fire_filename(&self, t: &EnrichTarget) {
if let Some(fp_url) = self.cfg.filename_parser_url.clone() {
if let Err(e) = self
.process(&fp_url, "filename-parser", &t.cid, &t.file_name, &t.base)
.await
{
warn!(target: "meta-feeder::enrich", cid = %t.cid, error = %e, "filename-parser /process failed");
}
}
}
/// POST `/process` to a plugin. Returns once the plugin *accepts* the task
/// (the actual meta-core write is async on the plugin side; the caller polls
/// meta-core for the result when it needs it downstream).
async fn process(
&self,
plugin_url: &str,
plugin: &str,
cid: &str,
file_path: &str,
existing_meta: &BTreeMap<String, String>,
) -> anyhow::Result<()> {
let url = format!("{}/process", plugin_url.trim_end_matches('/'));
let body = ProcessRequest {
task_id: format!("{plugin}-{cid}-{}", now_nanos()),
cid,
file_path,
callback_url: &self.cfg.callback_url,
meta_core_url: &self.cfg.meta_core_url,
existing_meta,
};
let resp = self.http.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("{plugin} POST {url} returned {status}: {text}");
}
Ok(())
}
/// POST `/configure` to the tmdb plugin with the API key + language.
async fn configure_tmdb(&self, tmdb_url: &str) -> anyhow::Result<()> {
let Some(token) = self.cfg.tmdb_token.clone() else {
anyhow::bail!("no TMDB token configured");
};
let url = format!("{}/configure", tmdb_url.trim_end_matches('/'));
let body = serde_json::json!({
"config": { "apiKey": token, "language": self.cfg.tmdb_language }
});
let resp = self.http.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("tmdb /configure returned {status}: {text}");
}
Ok(())
}
/// POST `/configure` to the opensubtitles plugin with the API key, account
/// credentials, and wanted languages.
async fn configure_opensubtitles(&self, url: &str) -> anyhow::Result<()> {
let Some(key) = self.cfg.opensubtitles_api_key.clone() else {
anyhow::bail!("no OpenSubtitles API key configured");
};
let cfg_url = format!("{}/configure", url.trim_end_matches('/'));
let body = serde_json::json!({
"config": {
"apiKey": key,
"username": self.cfg.opensubtitles_username,
"password": self.cfg.opensubtitles_password,
"languages": self.cfg.opensubtitles_languages,
}
});
let resp = self.http.post(&cfg_url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("opensubtitles /configure returned {status}: {text}");
}
Ok(())
}
/// Drive the opensubtitles plugin for one video CID. Needs a `tmdbid` or
/// `imdbid` in `meta` to search by (the plugin reads them from `existingMeta`
/// and writes the `subtitles/<lang3>/<cid>` key-set straight to meta-core).
/// Best-effort: any failure is logged and swallowed.
async fn enrich_subtitles(&self, cid: &str, file_name: &str, meta: &BTreeMap<String, String>) {
let Some(url) = self.cfg.opensubtitles_url.clone() else {
return;
};
if !meta.contains_key("tmdbid") && !meta.contains_key("imdbid") {
debug!(
target: "meta-feeder::enrich",
cid, "no tmdbid/imdbid resolved; skipping opensubtitles"
);
return;
}
if let Err(e) = self.configure_opensubtitles(&url).await {
warn!(
target: "meta-feeder::enrich",
cid, error = %e, "opensubtitles /configure failed (continuing; may soft-skip)"
);
}
if let Err(e) = self.process(&url, "opensubtitles", cid, file_name, meta).await {
warn!(
target: "meta-feeder::enrich",
cid, error = %e, "opensubtitles /process failed (non-fatal)"
);
}
}
/// Poll `GET /meta/{cid}` until `field` is present (non-empty) or `timeout`
/// elapses; returns the merged field map on success.
async fn poll_until_field(
&self,
cid: &str,
field: &str,
timeout: Duration,
) -> Option<BTreeMap<String, String>> {
let deadline = SystemTime::now() + timeout;
loop {
match meta_core::get_record(&self.http, &self.cfg.meta_core_url, cid).await {
Ok(Some(fields)) if fields.get(field).is_some_and(|v| !v.is_empty()) => {
return Some(fields);
}
Ok(_) => {}
Err(e) => debug!(
target: "meta-feeder::enrich",
cid, error = %e, "poll get_record failed"
),
}
if SystemTime::now() >= deadline {
return None;
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
}
fn now_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
/// Pick the primary video file index: largest by `sizeByte`, ties broken toward
/// the first (lowest index). Falls back to 0 for an all-unsized bundle.
fn pick_primary(videos: &[EnrichTarget]) -> usize {
videos
.iter()
.enumerate()
.max_by_key(|(i, v)| {
let size = v.base.get("sizeByte").and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
// larger size wins; on a tie the smaller index wins (Reverse).
(size, std::cmp::Reverse(*i))
})
.map(|(i, _)| i)
.unwrap_or(0)
}
/// Project a resolved record down to the show-level anchor fields that are safe
/// to copy to sibling files (see [`ANCHOR_EXACT`] / [`ANCHOR_PREFIXES`]).
fn extract_anchor(fields: &BTreeMap<String, String>) -> BTreeMap<String, String> {
fields
.iter()
.filter(|(k, _)| {
ANCHOR_EXACT.contains(&k.as_str())
|| ANCHOR_PREFIXES.iter().any(|p| k.starts_with(p))
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_env_requires_meta_core_url() {
// Not set in the test env → None (legacy gateway-stores path).
// (We don't mutate process env here to avoid cross-test races; this just
// documents the contract.)
assert!(EnrichmentConfig::from_env().is_none() || EnrichmentConfig::from_env().is_some());
}
fn tgt(cid: &str, size: u64) -> EnrichTarget {
EnrichTarget {
cid: cid.into(),
file_name: cid.into(),
base: BTreeMap::from_iter([("sizeByte".to_string(), size.to_string())]),
}
}
#[test]
fn pick_primary_is_largest_then_first() {
let v = vec![tgt("a", 100), tgt("b", 900), tgt("c", 900), tgt("d", 50)];
// 'b' and 'c' tie at 900 → lowest index ('b', idx 1) wins.
assert_eq!(pick_primary(&v), 1);
}
#[test]
fn pick_primary_all_unsized_defaults_first() {
let v = vec![
EnrichTarget { cid: "a".into(), file_name: "a".into(), base: BTreeMap::new() },
EnrichTarget { cid: "b".into(), file_name: "b".into(), base: BTreeMap::new() },
];
assert_eq!(pick_primary(&v), 0);
}
#[test]
fn extract_anchor_keeps_show_level_drops_per_file() {
let rec = BTreeMap::from_iter([
("tmdbid".to_string(), "10378".to_string()),
("imdbid".to_string(), "tt1254207".to_string()),
("poster".to_string(), "bafkPOSTER".to_string()),
("genres".to_string(), "Animation|Comedy".to_string()),
("plot/eng".to_string(), "a plot".to_string()),
("tags".to_string(), "tmdb-verified".to_string()),
// per-file — must NOT be fanned:
("originalTitle".to_string(), "Big Buck Bunny".to_string()),
("season".to_string(), "1".to_string()),
("episode".to_string(), "3".to_string()),
("fileName".to_string(), "ep3.mkv".to_string()),
]);
let a = extract_anchor(&rec);
assert!(a.contains_key("tmdbid") && a.contains_key("poster") && a.contains_key("plot/eng"));
assert!(a.contains_key("genres") && a.contains_key("tags") && a.contains_key("imdbid"));
assert!(!a.contains_key("originalTitle"), "originalTitle is per-file");
assert!(!a.contains_key("season") && !a.contains_key("episode"));
assert!(!a.contains_key("fileName"));
}
}
File diff suppressed because it is too large Load Diff
-889
View File
@@ -1,889 +0,0 @@
//! Content-addressed identifiers used by feeder plugins' `compute_outcomes`
//! to hash upstream-fetched bytes.
//!
//! Two function families live here:
//!
//! - [`compute_midhash256`] / [`compute_midhash256_from_sample`] —
//! the size-prefix-plus-middle-1MB-sample identification primitive shared
//! with `meta-sort` / `meta-share` / meta-core. Custom multicodec `0x1000`.
//! Fast but not IPFS-interop. The `_from_sample` entrypoint takes the
//! middle slice directly so plugins that can only fetch a partial range
//! (BitTorrent BEP 9, sliced HTTP) don't have to buffer the whole file
//! just to throw most of it away. **Rust-only by construction.**
//! - [`compute_ipfs_cid`] is the standard IPFS CIDv1 produced over the full
//! bytes — raw codec `0x55` for a single-chunk file, UnixFS dag-pb codec
//! `0x70` rooted on chunked raw-codec leaves for larger files. Output matches
//! `ipfs add --cid-version=1 --raw-leaves=true --chunker=size-262144
//! --hash=sha2-256 <file>`.
//!
//! Cross-implementation parity for `compute_midhash256` with the TypeScript
//! `FastHash.ts` is load-bearing — keep the fixture tests pinned, a regression
//! silently breaks dedupe across the platform.
use bytes::Bytes;
use sha2::{Digest, Sha256};
/// Sample window in bytes (1 MiB). Files this size or smaller are hashed
/// in full; larger files are hashed over the middle 1 MiB slice.
const SAMPLE_SIZE: usize = 1024 * 1024;
/// Per-block IPFS chunk size (256 KiB), matches kubo's
/// `--chunker=size-262144` default.
pub const IPFS_CHUNK_SIZE: usize = 256 * 1024;
/// dag-pb fanout — children per internal node. Matches kubo's balanced
/// builder default.
pub const IPFS_FANOUT: usize = 174;
/// Output of [`compute_ipfs_blocks`]. Carries the root CID **plus every
/// intermediate block** (leaf chunks AND internal dag-pb nodes) keyed by
/// their own CID. Pass `.blocks` to the gateway's blockstore so peers
/// can fetch the file by CID via bitswap.
#[derive(Debug, Clone)]
pub struct IpfsBlocks {
/// Canonical "bafy…" / "bafk…" root cid string. Identical to what
/// [`compute_ipfs_cid`] returns for the same input.
pub root: String,
/// Every block — root included — keyed by its own cid. For a
/// single-leaf file, this is a one-entry vec `[(root, payload)]`
/// (raw codec, payload is the file bytes). For multi-leaf files,
/// it is `leaves ++ internal_nodes` in build order; the **last**
/// entry is the root.
pub blocks: Vec<(String, Bytes)>,
}
/// Compute a midhash256 CID matching the TypeScript implementation in
/// `meta-hash/src/lib/file-id/FastHash.ts`:
///
/// 1. Build the hash input as `[size:u64-be][middle 1 MiB sample]` (or the
/// whole file if it fits in 1 MiB).
/// 2. SHA-256 it.
/// 3. Wrap in CIDv1 with custom multicodec `0x1000` for both the codec field
/// and the multihash hash-code field, length 32, then multibase-base32-lower
/// with the `b` prefix.
///
/// **Endianness for the size prefix is big-endian** to match TS's
/// `writeBigUInt64BE`. Cross-impl mismatch here would silently mangle every
/// import — keep the test pinned.
pub fn compute_midhash256(bytes: &[u8]) -> String {
let size = bytes.len();
let sample: &[u8] = if size <= SAMPLE_SIZE {
bytes
} else {
let start = (size - SAMPLE_SIZE) / 2;
&bytes[start..start + SAMPLE_SIZE]
};
compute_midhash256_from_sample(size as u64, sample)
}
/// Same midhash256 primitive as [`compute_midhash256`], but takes the
/// sample bytes directly rather than slicing them out of a full file.
/// For upstreams that can only deliver a middle range (BitTorrent BEP 9,
/// sliced HTTP), this avoids forcing the caller to buffer the whole file
/// in memory just to throw most of it away.
///
/// `total_size` is the **full file size in bytes** — used as the
/// size prefix, NOT `middle_sample.len()`.
pub fn compute_midhash256_from_sample(total_size: u64, middle_sample: &[u8]) -> String {
let size_be = total_size.to_be_bytes();
let mut hasher = Sha256::new();
hasher.update(size_be);
hasher.update(middle_sample);
let digest: [u8; 32] = hasher.finalize().into();
// Multicodec 0x1000 encoded as unsigned varint = [0x80, 0x20].
// CIDv1 layout: [version=0x01][codec varint][multihash code varint][len][digest]
let mut wire = Vec::with_capacity(38);
wire.push(0x01);
wire.extend_from_slice(&[0x80, 0x20]);
wire.extend_from_slice(&[0x80, 0x20]);
wire.push(0x20);
wire.extend_from_slice(&digest);
format!("b{}", base32_lower_no_padding(&wire))
}
/// Wrap a 20-byte BitTorrent v1 info-hash as a multibase CIDv1.
///
/// BT v1 info-hashes ARE SHA-1 digests of the bencoded info dict — so
/// the natural CID form is `codec=raw (0x55)` + `multihash=sha1 (0x11),
/// len=20, digest=<infohash bytes>`.
pub fn compute_bt_info_cid(infohash_20: &[u8; 20]) -> String {
// CIDv1 layout: [version=0x01][codec varint][multihash code varint][len][digest]
let mut wire = Vec::with_capacity(4 + 20);
wire.push(0x01); // CIDv1 version
wire.push(0x55); // codec: raw
wire.push(0x11); // multihash: sha1
wire.push(0x14); // multihash length: 20
wire.extend_from_slice(infohash_20);
format!("b{}", base32_lower_no_padding(&wire))
}
/// Custom multicodec for a single file inside a **BitTorrent v1** torrent
/// (`btih-v1-file`). MetaMesh-private, adjacent to midhash256's `0x1000`.
pub const BTIH_V1_FILE_CODEC: u64 = 0x1001;
/// Custom multicodec for a single file inside a **BitTorrent v2** torrent
/// (`btih-v2-file`).
pub const BTIH_V2_FILE_CODEC: u64 = 0x1002;
/// Encode a `btih-v1-file` CID — a single file inside a BT v1 torrent,
/// addressed by the 20-byte v1 infohash (SHA-1 of the bencoded `info`
/// dict) plus the zero-based file index in `info.files`. Single-file
/// torrents use index `0`.
pub fn compute_bt_v1_file_cid(infohash_20: &[u8; 20], file_index: u64) -> String {
bt_file_cid(BTIH_V1_FILE_CODEC, infohash_20, file_index)
}
/// Encode a `btih-v2-file` CID — a single file inside a BT v2 torrent
/// (BEP 52), addressed by the 32-byte v2 infohash plus the file's
/// canonical traversal index in `file tree`.
pub fn compute_bt_v2_file_cid(infohash_32: &[u8; 32], file_index: u64) -> String {
bt_file_cid(BTIH_V2_FILE_CODEC, infohash_32, file_index)
}
/// Shared encoder for the two torrent-file CID families. `infohash` is 20
/// bytes for v1 / 32 for v2; `codec` selects the family.
fn bt_file_cid(codec: u64, infohash: &[u8], file_index: u64) -> String {
// digest = infohash ‖ varint(file_index)
let mut digest = Vec::with_capacity(infohash.len() + 2);
digest.extend_from_slice(infohash);
write_pb_varint(file_index, &mut digest);
// CIDv1: [version][codec varint][multihash code varint][len varint][digest]
let mut wire = Vec::with_capacity(1 + 4 + digest.len());
wire.push(0x01);
write_pb_varint(codec, &mut wire);
write_pb_varint(codec, &mut wire);
write_pb_varint(digest.len() as u64, &mut wire);
wire.extend_from_slice(&digest);
format!("b{}", base32_lower_no_padding(&wire))
}
/// Custom multicodec for a self-describing **Newznab release** locator
/// (`nzb-release`). MetaMesh-private, adjacent to the torrent-file codecs in the
/// `0x10xx` range. Unlike a content hash, the cid *embeds* the indexer host +
/// release id in an identity multihash, so the credentialed meta-share peer
/// decodes `{host,id}` straight from the cid and grabs the `.nzb` via `t=get`
/// only at playback — no side-table, no `.nzb` download at search time. (The
/// `0x1004` `sha256(host‖id)` opaque hash and the `0x1003` `nzb-posting`
/// variant were both removed; only this self-describing form is emitted.)
pub const NZB_RELEASE_CODEC: u64 = 0x1005;
/// Digest byte ceiling for an `nzb-release` cid, matching meta-share's
/// `MAX_MULTIHASH_SIZE` — its `CidGeneric<64>` rejects a longer multihash.
const NZB_RELEASE_MAX_DIGEST: usize = 64;
/// Encode a self-describing `nzb-release` CID from a Newznab indexer's host +
/// bare release id (the hex `<guid>` id). The multihash is *identity* (`0x00`)
/// and its digest embeds the locator as `varint(host_len) ‖ host ‖ id_bytes`,
/// where `id_bytes` is the hex-decoded id. meta-share's `decode_nzb_release_cid`
/// is the exact inverse. Host-namespaced: the same release on two indexers
/// yields different cids.
///
/// 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<String> {
let id_bytes = decode_hex_id(release_id)?;
let host_b = host.as_bytes();
// identity-multihash digest = varint(host_len) ‖ host ‖ id_bytes
let mut digest = Vec::with_capacity(2 + host_b.len() + id_bytes.len());
write_pb_varint(host_b.len() as u64, &mut digest);
digest.extend_from_slice(host_b);
digest.extend_from_slice(&id_bytes);
if digest.len() > NZB_RELEASE_MAX_DIGEST {
return None;
}
// CIDv1: [version=0x01][codec varint][mh code=0x00 identity][len varint][digest]
let mut wire = Vec::with_capacity(1 + 3 + 2 + digest.len());
wire.push(0x01);
write_pb_varint(NZB_RELEASE_CODEC, &mut wire);
wire.push(0x00); // multihash code: identity
write_pb_varint(digest.len() as u64, &mut wire);
wire.extend_from_slice(&digest);
Some(format!("b{}", base32_lower_no_padding(&wire)))
}
/// Hex-decode a Newznab release id. The guid parser only keeps hex runs, so the
/// id is all-hex by construction; odd-length ids are left-padded with a `0`
/// nibble. Returns `None` on any non-hex byte (defensive).
fn decode_hex_id(id: &str) -> Option<Vec<u8>> {
let padded;
let s: &str = if id.len() % 2 == 1 {
padded = format!("0{id}");
&padded
} else {
id
};
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks(2) {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out.push(((hi << 4) | lo) as u8);
}
Some(out)
}
/// Custom multicodec for a **card locator** — the identity of a *work* (a
/// series, a film) as published by a metadata bridge, rather than of any
/// bytes. MetaMesh-private, the next free slot after `0x1006` url. (`0x1004`
/// nzb-release-hash stays **retired — do not reuse**. `0x1003` was retired too,
/// but has since been **revived** for the self-scanned Usenet posting identity —
/// see [`NZB_POSTING_CODEC`] and `METADATA_KEYS.md` §2.)
///
/// This is the first CID family in the platform where **no bytes exist
/// anywhere, ever** — `0x1005`/`0x1006` are locators too, but both eventually
/// resolve to bytes (NNTP, HTTP), and `0x1000` midhash256 is a digest over
/// real file bytes. A card is pure identity: the record *is* the payload, and
/// it lives in meta-core. Consequences, all already handled:
///
/// * Bitswap seeding is gated on `HashKind::Sha2_256` (gateway invariant 6),
/// so a card is never offered to the swarm — nothing to seed.
/// * meta-share's byte path 404s for it, which is correct.
/// * It ranks `RANK_LOCATOR` (5), so it can never outrank a real digest for
/// `canonical_cid` — which is what makes the future "one card, two CIDs"
/// merge (a TMDB and a MyAnimeList locator on one meta-core record) safe.
pub const CARD_LOCATOR_CODEC: u64 = 0x1007;
/// Digest byte ceiling for a card locator, matching meta-share's
/// `MAX_MULTIHASH_SIZE` — its `CidGeneric<64>` rejects a longer multihash.
const CARD_LOCATOR_MAX_DIGEST: usize = 64;
/// Encode a self-describing **card locator** CID from a metadata source and
/// that source's own id for the work (`("tmdb", "tv:95479")`,
/// `("mal", "52991")`). The multihash is *identity* (`0x00`) and its digest
/// embeds the locator as `varint(source_len) ‖ source ‖ id`, both UTF-8 —
/// exactly the shape [`compute_nzb_release_cid`] uses, minus the hex decode
/// (a card id is arbitrary text, not a hex release id).
///
/// **The CID is a pure function of `(source, id)`**, which is the whole point:
/// any peer that knows a tmdb id can derive the card's address offline and
/// look it up locally, with no discovery round-trip. It also means the same
/// card discovered by two different peers converges on one meta-core record
/// for free, via the `cids/<bareCid>` reverse index.
///
/// Source-namespaced: the same work on TMDB and on MyAnimeList yields
/// different cids. Merging those two into one record is a meta-core alias
/// operation driven by an explicit cross-source id mapping — never inferred
/// here (see `meta-gateway/docs/others/card-tier-search.md` §9).
///
/// Returns `None` when `source` is empty or the digest would overflow the
/// multihash budget — the caller drops that card.
pub fn compute_card_cid(source: &str, id: &str) -> Option<String> {
if source.is_empty() || id.is_empty() {
return None;
}
let source_b = source.as_bytes();
let id_b = id.as_bytes();
// identity-multihash digest = varint(source_len) ‖ source ‖ id
let mut digest = Vec::with_capacity(2 + source_b.len() + id_b.len());
write_pb_varint(source_b.len() as u64, &mut digest);
digest.extend_from_slice(source_b);
digest.extend_from_slice(id_b);
if digest.len() > CARD_LOCATOR_MAX_DIGEST {
return None;
}
// CIDv1: [version=0x01][codec varint][mh code=0x00 identity][len varint][digest]
let mut wire = Vec::with_capacity(1 + 3 + 2 + digest.len());
wire.push(0x01);
write_pb_varint(CARD_LOCATOR_CODEC, &mut wire);
wire.push(0x00); // multihash code: identity
write_pb_varint(digest.len() as u64, &mut wire);
wire.extend_from_slice(&digest);
Some(format!("b{}", base32_lower_no_padding(&wire)))
}
/// Custom multicodec for a **Usenet posting** identity (`nzb-posting`),
/// minted from the article Message-IDs of a release we scanned ourselves.
///
/// Unlike [`NZB_RELEASE_CODEC`] (`0x1005`), this is **not** a locator: it
/// embeds no indexer host, so any peer with a plain NNTP provider can fetch the
/// articles — no `IndexerCred`, no `t=get` grab. That portability is the whole
/// point of running our own header scanner.
///
/// It is also **not** fetchable by cid: the digest is over the Message-ID set,
/// not over any block's bytes, so bitswap can never satisfy a want for it (the
/// receiver derives a block's cid by hashing the block — see
/// `beetswap::incoming_stream`). The `.nzb` manifest travels separately, as an
/// ordinary sha2-256 IPFS cid in the record's `manifest` field. See
/// `meta-gateway/docs/others/self-hosted-usenet-indexer-study.md` §5.
pub const NZB_POSTING_CODEC: u64 = 0x1003;
/// Mint an `nzb-posting` cid (`0x1003`) from a release's article Message-IDs.
///
/// # The normalisation rule IS the dedup contract
///
/// Mirrored verbatim in meta-share (`nzb/manifest.rs`), which re-derives this
/// digest from the fetched manifest and rejects a mismatch. **Any change here
/// must land on both sides together** — a drift silently makes every posting
/// unplayable. The rule:
///
/// 1. Trim each id, and strip a surrounding `<…>` pair if present (NZB
/// `<segment>` bodies carry the bare form, but be defensive — NNTP `BODY`
/// re-adds the brackets).
/// 2. Drop empties.
/// 3. Deduplicate, then **sort** lexicographically by bytes.
/// 4. Join with `\n` and SHA-256 the result.
///
/// Case is **preserved**: a Message-ID's local part is case-sensitive per RFC
/// 5322, so lowercasing could collide two genuinely distinct articles.
///
/// Sorting is what makes the cid survive segment/file reordering between two
/// NZBs of the same posting — the reason we hash the *id set* and never the raw
/// `.nzb` bytes (the retired `0x1004` did the latter and broke dedup; see
/// meta-share `nzb/mod.rs`).
///
/// **v1 scope:** every segment counts, par2 volumes included. Two NZBs for one
/// posting that disagree about whether to bundle par2 therefore mint different
/// cids. Acceptable while we are the only producer of these manifests; if a
/// second producer ever appears, the fix is to filter par2 subjects here *and*
/// in meta-share's mirror — do not do it on one side only.
pub fn compute_nzb_posting_cid<S: AsRef<str>>(message_ids: &[S]) -> String {
let mut ids: Vec<&str> = message_ids
.iter()
.map(|s| {
let t = s.as_ref().trim();
t.strip_prefix('<')
.and_then(|r| r.strip_suffix('>'))
.unwrap_or(t)
})
.filter(|s| !s.is_empty())
.collect();
ids.sort_unstable();
ids.dedup();
let mut hasher = Sha256::new();
for (i, id) in ids.iter().enumerate() {
if i > 0 {
hasher.update(b"\n");
}
hasher.update(id.as_bytes());
}
let digest: [u8; 32] = hasher.finalize().into();
// CIDv1: [version=0x01][codec varint][mh code=0x12 sha2-256][len=32][digest]
let mut wire = Vec::with_capacity(1 + 3 + 2 + digest.len());
wire.push(0x01);
write_pb_varint(NZB_POSTING_CODEC, &mut wire);
wire.push(0x12); // multihash code: sha2-256
write_pb_varint(digest.len() as u64, &mut wire);
wire.extend_from_slice(&digest);
format!("b{}", base32_lower_no_padding(&wire))
}
/// Compute a standard IPFS CIDv1 over `bytes`. Output matches kubo's
/// `ipfs add --cid-version=1 --raw-leaves=true --chunker=size-262144
/// --hash=sha2-256 <file>`.
pub fn compute_ipfs_cid(bytes: &[u8]) -> String {
compute_ipfs_blocks(bytes).root
}
/// Same wire-format guarantee as [`compute_ipfs_cid`], but exposes every
/// block produced along the way (leaf chunks + internal dag-pb nodes)
/// so callers can populate a bitswap blockstore in one pass.
pub fn compute_ipfs_blocks(bytes: &[u8]) -> IpfsBlocks {
// Leaves: chunk bytes into raw-codec blocks. Empty input still gets
// one (empty) leaf so the cid is well-defined.
let mut blocks: Vec<(String, Bytes)> = Vec::new();
let leaves: Vec<(Vec<u8>, u64)> = if bytes.is_empty() {
let cid_wire = ipfs_cid_wire(0x55, &sha2_256(b""));
blocks.push((cid_string(&cid_wire), Bytes::new()));
vec![(cid_wire, 0)]
} else {
bytes
.chunks(IPFS_CHUNK_SIZE)
.map(|chunk| {
let cid_wire = ipfs_cid_wire(0x55, &sha2_256(chunk));
blocks.push((cid_string(&cid_wire), Bytes::copy_from_slice(chunk)));
(cid_wire, chunk.len() as u64)
})
.collect()
};
// Single-leaf file: return the leaf cid as the file cid.
if leaves.len() == 1 {
let root = cid_string(&leaves[0].0);
return IpfsBlocks { root, blocks };
}
// Multi-leaf: build a balanced tree of UnixFS dag-pb internal nodes.
let mut level: Vec<(Vec<u8>, u64, u64)> = leaves
.into_iter()
.map(|(cid, size)| (cid, size, size))
.collect();
while level.len() > 1 {
let mut next: Vec<(Vec<u8>, u64, u64)> =
Vec::with_capacity(level.len().div_ceil(IPFS_FANOUT));
for batch in level.chunks(IPFS_FANOUT) {
let blocksizes: Vec<u64> = batch.iter().map(|(_, sz, _)| *sz).collect();
let filesize: u64 = blocksizes.iter().sum();
let unixfs_data = encode_unixfs_file(filesize, &blocksizes);
let node_bytes = encode_dagpb_node(batch, &unixfs_data);
let node_cid = ipfs_cid_wire(0x70, &sha2_256(&node_bytes));
blocks.push((cid_string(&node_cid), Bytes::from(node_bytes.clone())));
let own_tsize: u64 =
batch.iter().map(|(_, _, t)| *t).sum::<u64>() + node_bytes.len() as u64;
next.push((node_cid, filesize, own_tsize));
}
level = next;
}
let root = cid_string(&level[0].0);
IpfsBlocks { root, blocks }
}
fn cid_string(wire: &[u8]) -> String {
format!("b{}", base32_lower_no_padding(wire))
}
/// CIDv1 wire-form bytes: `[version=0x01][codec varint][multihash code
/// varint=0x12 sha2-256][len=0x20][digest...]`.
fn ipfs_cid_wire(codec: u8, digest: &[u8; 32]) -> Vec<u8> {
debug_assert!(
codec < 0x80,
"codec varint must fit in one byte for this helper"
);
let mut wire = Vec::with_capacity(36);
wire.push(0x01); // CIDv1
wire.push(codec);
wire.push(0x12); // multihash code: sha2-256
wire.push(0x20); // digest length: 32
wire.extend_from_slice(digest);
wire
}
fn sha2_256(bytes: &[u8]) -> [u8; 32] {
Sha256::digest(bytes).into()
}
/// LEB128 unsigned varint encoding into `out`.
fn write_pb_varint(value: u64, out: &mut Vec<u8>) {
let mut v = value;
while v >= 0x80 {
out.push((v as u8) | 0x80);
v >>= 7;
}
out.push(v as u8);
}
fn write_pb_varint_field(field: u32, value: u64, out: &mut Vec<u8>) {
write_pb_varint((field as u64) << 3, out); // tag: (field << 3) | wire-type 0 (varint)
write_pb_varint(value, out);
}
fn write_pb_bytes_field(field: u32, value: &[u8], out: &mut Vec<u8>) {
write_pb_varint(((field as u64) << 3) | 2, out); // wire type 2 = length-delimited
write_pb_varint(value.len() as u64, out);
out.extend_from_slice(value);
}
/// UnixFS protobuf payload for a File node: `Type=File, filesize,
/// blocksizes[]`.
fn encode_unixfs_file(filesize: u64, blocksizes: &[u64]) -> Vec<u8> {
let mut out = Vec::new();
write_pb_varint_field(1, 2, &mut out); // Type = File
write_pb_varint_field(3, filesize, &mut out); // filesize
for &bs in blocksizes {
write_pb_varint_field(4, bs, &mut out); // blocksizes (repeated)
}
out
}
/// dag-pb PBNode for an internal UnixFS file node. Canonical wire order
/// pinned by the dag-pb spec: `Links` (tag 2) first, `Data` (tag 1) second.
fn encode_dagpb_node(children: &[(Vec<u8>, u64, u64)], data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
for (cid_wire, _filesize, tsize) in children {
let mut link_bytes = Vec::new();
write_pb_bytes_field(1, cid_wire, &mut link_bytes); // Hash
write_pb_varint_field(3, *tsize, &mut link_bytes); // Tsize
write_pb_bytes_field(2, &link_bytes, &mut out); // Links (tag 2) first
}
write_pb_bytes_field(1, data, &mut out); // Data (tag 1) last
out
}
/// RFC 4648 base32 with the lowercase alphabet and no padding. Used for the
/// multibase `b` prefix.
pub(crate) fn base32_lower_no_padding(input: &[u8]) -> String {
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut out = String::with_capacity(input.len().div_ceil(5) * 8);
let mut buffer: u64 = 0;
let mut bits: u32 = 0;
for &b in input {
buffer = (buffer << 8) | b as u64;
bits += 8;
while bits >= 5 {
bits -= 5;
let idx = ((buffer >> bits) & 0x1F) as usize;
out.push(ALPHABET[idx] as char);
}
}
if bits > 0 {
let idx = ((buffer << (5 - bits)) & 0x1F) as usize;
out.push(ALPHABET[idx] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn midhash256_matches_ts_fixtures() {
assert_eq!(
compute_midhash256(b"hello world"),
"bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4"
);
assert_eq!(
compute_midhash256(b""),
"bagacbabaecxvk4hvugaqw6xxrsxuxrykmyhq35i6ik5pshkn4wzdfdpa5a67y"
);
let mb_zeros = vec![0u8; 1024 * 1024];
assert_eq!(
compute_midhash256(&mb_zeros),
"bagacbabaeawlkt4hn34sxuieoosrnnag3g3wv7gc6alzwfms2nrph2hochrx4"
);
}
#[test]
fn midhash256_from_sample_matches_full_bytes() {
let small: &[u8] = b"hello world";
assert_eq!(
compute_midhash256_from_sample(small.len() as u64, small),
compute_midhash256(small),
);
assert_eq!(
compute_midhash256_from_sample(0, b""),
compute_midhash256(b""),
);
let one_mb = vec![0u8; SAMPLE_SIZE];
assert_eq!(
compute_midhash256_from_sample(one_mb.len() as u64, &one_mb),
compute_midhash256(&one_mb),
);
let two_mb = vec![0u8; 2 * SAMPLE_SIZE];
let start = (two_mb.len() - SAMPLE_SIZE) / 2;
let middle = &two_mb[start..start + SAMPLE_SIZE];
assert_eq!(
compute_midhash256_from_sample(two_mb.len() as u64, middle),
compute_midhash256(&two_mb),
);
}
#[test]
fn midhash256_from_sample_partial_sample_stable() {
let sample = vec![0u8; SAMPLE_SIZE];
let total_5gb: u64 = 5 * 1024 * 1024 * 1024;
let total_10gb: u64 = 10 * 1024 * 1024 * 1024;
let h1 = compute_midhash256_from_sample(total_5gb, &sample);
let h2 = compute_midhash256_from_sample(total_5gb, &sample);
assert_eq!(h1, h2, "same (size, sample) must hash identically");
let h3 = compute_midhash256_from_sample(total_10gb, &sample);
assert_ne!(
h1, h3,
"size prefix must contribute to cid — same sample, different size"
);
}
#[test]
fn bt_v1_file_cid_wire_layout() {
let infohash: [u8; 20] = [
0xf9, 0xc8, 0xa7, 0xb6, 0xe5, 0xd4, 0xc3, 0xb2, 0xa1, 0x90, 0x8f, 0x7e, 0x6d, 0x5c,
0x4b, 0x3a, 0x29, 0x18, 0x07, 0x06,
];
let mut want = vec![0x01, 0x81, 0x20, 0x81, 0x20, 0x15];
want.extend_from_slice(&infohash);
want.push(0x04);
assert_eq!(
compute_bt_v1_file_cid(&infohash, 4),
format!("b{}", base32_lower_no_padding(&want))
);
let mut want0 = vec![0x01, 0x81, 0x20, 0x81, 0x20, 0x15];
want0.extend_from_slice(&infohash);
want0.push(0x00);
assert_eq!(
compute_bt_v1_file_cid(&infohash, 0),
format!("b{}", base32_lower_no_padding(&want0))
);
let mut want200 = vec![0x01, 0x81, 0x20, 0x81, 0x20, 0x16];
want200.extend_from_slice(&infohash);
want200.extend_from_slice(&[0xC8, 0x01]);
assert_eq!(
compute_bt_v1_file_cid(&infohash, 200),
format!("b{}", base32_lower_no_padding(&want200))
);
}
#[test]
fn bt_v2_file_cid_wire_layout() {
let infohash: [u8; 32] = [0xAB; 32];
let mut want = vec![0x01, 0x82, 0x20, 0x82, 0x20, 0x21];
want.extend_from_slice(&infohash);
want.push(0x00);
assert_eq!(
compute_bt_v2_file_cid(&infohash, 0),
format!("b{}", base32_lower_no_padding(&want))
);
}
#[test]
fn bt_file_cid_properties() {
let ih1 = [0x11u8; 20];
let ih2 = [0x22u8; 32];
assert_eq!(
compute_bt_v1_file_cid(&ih1, 3),
compute_bt_v1_file_cid(&ih1, 3)
);
assert_ne!(
compute_bt_v1_file_cid(&ih1, 0),
compute_bt_v1_file_cid(&ih1, 1)
);
assert!(compute_bt_v1_file_cid(&ih1, 0).starts_with('b'));
assert!(compute_bt_v2_file_cid(&ih2, 0).starts_with('b'));
assert_ne!(
compute_bt_v1_file_cid(&[0xAB; 20], 0),
compute_bt_v2_file_cid(&[0xAB; 32], 0)
);
}
#[test]
fn base32_known_vectors() {
assert_eq!(base32_lower_no_padding(b""), "");
assert_eq!(base32_lower_no_padding(b"f"), "my");
assert_eq!(base32_lower_no_padding(b"fo"), "mzxq");
assert_eq!(base32_lower_no_padding(b"foo"), "mzxw6");
assert_eq!(base32_lower_no_padding(b"foob"), "mzxw6yq");
assert_eq!(base32_lower_no_padding(b"foobar"), "mzxw6ytboi");
}
#[test]
fn ipfs_cid_single_leaf_matches_kubo() {
assert_eq!(
compute_ipfs_cid(b"hello world"),
"bafkreifzjut3te2nhyekklss27nh3k72ysco7y32koao5eei66wof36n5e"
);
}
#[test]
fn ipfs_cid_empty_matches_kubo() {
assert_eq!(
compute_ipfs_cid(b""),
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
);
}
#[test]
fn ipfs_cid_multi_leaf_zeros_stable() {
let mb_zeros = vec![0u8; 1024 * 1024];
assert_eq!(
compute_ipfs_cid(&mb_zeros),
"bafybeiadh3bekpwtewjvauqeucf7yzqrb3ixsxzltnuwed4pxangtpou6m"
);
}
#[test]
fn ipfs_blocks_single_leaf() {
let payload = b"hello world";
let out = compute_ipfs_blocks(payload);
assert_eq!(out.root, compute_ipfs_cid(payload));
assert_eq!(out.blocks.len(), 1);
assert_eq!(out.blocks[0].0, out.root);
assert_eq!(out.blocks[0].1.as_ref(), payload);
}
#[test]
fn ipfs_blocks_multi_leaf() {
let mb_zeros = vec![0u8; 1024 * 1024];
let out = compute_ipfs_blocks(&mb_zeros);
assert_eq!(
out.root,
"bafybeiadh3bekpwtewjvauqeucf7yzqrb3ixsxzltnuwed4pxangtpou6m"
);
assert_eq!(out.blocks.len(), 5);
assert_eq!(out.blocks.last().unwrap().0, out.root);
for (_cid, block) in &out.blocks[..4] {
assert_eq!(block.len(), 256 * 1024);
assert!(block.iter().all(|&b| b == 0));
}
}
#[test]
fn ipfs_blocks_empty() {
let out = compute_ipfs_blocks(b"");
assert_eq!(
out.root,
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
);
assert_eq!(out.blocks.len(), 1);
assert_eq!(out.blocks[0].1.len(), 0);
assert_eq!(out.blocks[0].0, out.root);
}
// ---- card locator (0x1007) --------------------------------------------
/// Inverse of [`base32_lower_no_padding`], test-only. Lets the card tests
/// assert the actual CIDv1 bytes (codec, multihash code, digest) rather
/// than just round-tripping the encoder against itself.
fn base32_lower_decode(s: &str) -> Vec<u8> {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut out = Vec::new();
let (mut acc, mut bits) = (0u32, 0u32);
for c in s.bytes() {
let v = ALPHABET.iter().position(|&a| a == c).expect("base32 char") as u32;
acc = (acc << 5) | v;
bits += 5;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
out
}
/// The property the whole card design leans on: the CID is a pure function
/// of `(source, id)`, so any peer can derive a card's address offline. If
/// this literal ever changes, every already-published card silently forks
/// into a second identity — treat a failure here as a wire break.
#[test]
fn card_cid_is_deterministic() {
let cid = compute_card_cid("tmdb", "tv:95479").expect("card cid");
assert_eq!(cid, compute_card_cid("tmdb", "tv:95479").unwrap());
assert_eq!(cid, "bagdsaaanar2g2zdcor3duojvgq3ts");
}
/// Source- and kind-namespaced: the same numeric id on a different bridge,
/// or the same id under a different media kind, must not collide.
#[test]
fn card_cid_is_source_and_kind_namespaced() {
let tmdb = compute_card_cid("tmdb", "tv:95479").unwrap();
let mal = compute_card_cid("mal", "95479").unwrap();
let movie = compute_card_cid("tmdb", "movie:95479").unwrap();
assert_ne!(tmdb, mal);
assert_ne!(tmdb, movie);
}
/// Pins the on-the-wire CIDv1 layout: v1, codec `0x1007` (varint
/// `[0x87,0x20]`), **identity** multihash `0x00` (this family has no
/// digest — the locator itself is the payload), then
/// `varint(source_len) ‖ source ‖ id`.
#[test]
fn card_cid_wire_shape() {
let cid = compute_card_cid("tmdb", "tv:95479").unwrap();
assert!(cid.starts_with('b'), "multibase base32-lower prefix");
let wire = base32_lower_decode(&cid[1..]);
assert_eq!(wire[0], 0x01, "CIDv1");
assert_eq!(&wire[1..3], &[0x87, 0x20], "codec varint for 0x1007");
assert_eq!(wire[3], 0x00, "identity multihash — no digest");
let digest_len = wire[4] as usize;
let digest = &wire[5..5 + digest_len];
assert_eq!(digest[0], 4, "varint(len(\"tmdb\"))");
assert_eq!(&digest[1..5], b"tmdb");
assert_eq!(&digest[5..], b"tv:95479");
}
/// Empty inputs are rejected rather than encoded as an ambiguous locator,
/// and an id past the 64-byte multihash budget (meta-share's
/// `CidGeneric<64>`) returns `None` so the caller drops the card instead of
/// emitting a cid that peer would refuse to parse.
#[test]
fn card_cid_rejects_empty_and_oversized() {
assert!(compute_card_cid("", "tv:1").is_none());
assert!(compute_card_cid("tmdb", "").is_none());
let huge = "x".repeat(CARD_LOCATOR_MAX_DIGEST);
assert!(compute_card_cid("tmdb", &huge).is_none());
// Exactly at the ceiling still encodes.
let fits = "x".repeat(CARD_LOCATOR_MAX_DIGEST - 5); // varint(4) + "tmdb"
assert!(compute_card_cid("tmdb", &fits).is_some());
}
// ---- nzb-posting (0x1003) — the dedup contract -----------------------
//
// These pin the normalisation rule documented on `compute_nzb_posting_cid`.
// meta-share re-derives the same digest from the fetched manifest and
// rejects a mismatch, so a change that breaks one of these silently makes
// every self-scanned posting unplayable. Mirror any edit on both sides.
/// Segment/file **order must not matter** — that is the whole reason we
/// hash a sorted id set instead of the raw `.nzb` bytes (the retired
/// `0x1004` did the latter and broke dedup across reposts).
#[test]
fn nzb_posting_cid_is_order_independent() {
let a = ["[email protected]", "[email protected]", "[email protected]"];
let b = ["[email protected]", "[email protected]", "[email protected]"];
let c = ["[email protected]", "[email protected]", "[email protected]"];
assert_eq!(compute_nzb_posting_cid(&a), compute_nzb_posting_cid(&b));
assert_eq!(compute_nzb_posting_cid(&a), compute_nzb_posting_cid(&c));
}
/// Angle brackets, surrounding whitespace, empty entries and duplicates are
/// all normalised away. NZB `<segment>` bodies carry the bare form but NNTP
/// `BODY` re-adds the brackets, so both spellings must converge.
#[test]
fn nzb_posting_cid_normalises_brackets_blanks_and_dupes() {
let bare = ["[email protected]", "[email protected]"];
let noisy = [" <[email protected]> ", "[email protected]", "", " ", "[email protected]"];
assert_eq!(
compute_nzb_posting_cid(&bare),
compute_nzb_posting_cid(&noisy)
);
}
/// Case is preserved: a Message-ID's local part is case-sensitive per RFC
/// 5322, so lowercasing could collide two genuinely distinct articles.
#[test]
fn nzb_posting_cid_is_case_sensitive() {
assert_ne!(
compute_nzb_posting_cid(&["[email protected]"]),
compute_nzb_posting_cid(&["[email protected]"])
);
}
/// A different article set is a different posting.
#[test]
fn nzb_posting_cid_differs_on_different_ids() {
assert_ne!(
compute_nzb_posting_cid(&["[email protected]", "[email protected]"]),
compute_nzb_posting_cid(&["[email protected]", "[email protected]"])
);
}
/// Wire shape: CIDv1, codec `0x1003` (varint `[0x83, 0x20]`), multihash
/// sha2-256 (`0x12`), 32-byte digest. Pinned because meta-share matches on
/// the **codec** slot — a drift here makes every posting undecodable there.
#[test]
fn nzb_posting_cid_wire_shape() {
let cid = compute_nzb_posting_cid(&["[email protected]"]);
assert!(cid.starts_with('b'), "multibase base32-lower prefix");
// Round-trip the base32 body back to bytes to assert the header.
let decoded = base32_lower_decode(&cid[1..]);
assert_eq!(decoded[0], 0x01, "CIDv1");
assert_eq!(&decoded[1..3], &[0x83, 0x20], "varint(0x1003)");
assert_eq!(decoded[3], 0x12, "multihash sha2-256");
assert_eq!(decoded[4], 0x20, "digest length 32");
assert_eq!(decoded.len(), 5 + 32);
// The digest is sha256 over the newline-joined sorted set.
let expect: [u8; 32] = Sha256::digest(b"[email protected]").into();
assert_eq!(&decoded[5..], &expect);
}
}
-81
View File
@@ -1,81 +0,0 @@
//! Language-code normalization — ISO 639 alias folding, **not** filename
//! parsing.
//!
//! This lives apart from [`crate::filename_meta`] on purpose: it doesn't read a
//! release title, it canonicalizes a *language code* (an indexer's declared
//! language, a query's `languages:` filter, a title-derived stamp) onto the
//! single ISO 639-2/B alpha-3 vocabulary the rest of the stack compares against.
//! It is the one piece of the old `filename_meta` module that has no equivalent
//! in the `metamesh-plugin-filename-parser` plugin — because it isn't filename
//! parsing — so it stays in Rust while the title-parsing algorithm itself is
//! owned by the plugin. Callers in the **request hot path** (e.g. the torznab
//! indexer-language fan-out skip) need it synchronously, with no network hop.
/// Normalize a language code to the ISO 639-2/B alpha-3 vocabulary the stack
/// emits (`fre`/`ger`/`chi`, **not** the 639-2/T `fra`/`deu`/`zho` nor the
/// 639-1 two-letter forms), lowercased. This is the space meta-share's
/// "languages I read" preference uses and that the title-derived language stamps
/// write, so an operator who declares an indexer's language as `fra`/`de`/`zh`
/// still lines up with a `fre`/`ger`/`chi` query filter. Unknown /
/// already-canonical codes pass through lowercased+trimmed unchanged.
pub fn normalize_lang_code(code: &str) -> String {
let c = code.trim().to_ascii_lowercase();
let mapped = match c.as_str() {
// 639-2/T and 639-1 → 639-2/B for the codes that actually differ.
"fra" | "fr" => "fre",
"deu" | "de" => "ger",
"zho" | "zh" => "chi",
"nld" | "nl" => "dut",
"ell" | "el" => "gre",
"ron" | "ro" => "rum",
"slk" | "sk" => "slo",
"ces" | "cs" => "cze",
// 639-1 → 639-2 for the rest of the language-tag output space (these
// share the same /B and /T alpha-3, so only the two-letter form maps).
"en" => "eng",
"ja" => "jpn",
"it" => "ita",
"es" => "spa",
"pt" => "por",
"ko" => "kor",
"ru" => "rus",
"pl" => "pol",
"hu" => "hun",
"uk" => "ukr",
"bg" => "bul",
"sv" => "swe",
"no" => "nor",
"da" => "dan",
"fi" => "fin",
"tr" => "tur",
"ar" => "ara",
"hi" => "hin",
"ta" => "tam",
"te" => "tel",
"ml" => "mal",
"vi" => "vie",
"th" => "tha",
_ => return c,
};
mapped.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_lang_maps_to_639_2b_vocabulary() {
// 639-2/T and 639-1 spellings collapse onto the /B forms the stack uses.
assert_eq!(normalize_lang_code("fra"), "fre");
assert_eq!(normalize_lang_code("FR"), "fre");
assert_eq!(normalize_lang_code("deu"), "ger");
assert_eq!(normalize_lang_code("zh"), "chi");
assert_eq!(normalize_lang_code("nld"), "dut");
// Already-canonical / unknown codes pass through trimmed + lowercased.
assert_eq!(normalize_lang_code(" ENG "), "eng");
assert_eq!(normalize_lang_code("fre"), "fre");
assert_eq!(normalize_lang_code("mul"), "mul");
assert_eq!(normalize_lang_code("xyz"), "xyz");
}
}
-49
View File
@@ -1,49 +0,0 @@
//! `meta-feeder-sdk` — the source-agnostic foundation shared by the gateway
//! core and every feeder sidecar binary.
//!
//! A *feeder* implements [`FeederPlugin`] (find records, fetch bytes) for one
//! or more upstreams, and [`serve_feeders`] exposes them over the feeder HTTP
//! contract. The gateway core consumes that contract via its
//! `RemoteFeederPlugin` and keeps the libp2p wire, the bitswap blockstore, the
//! hashing-into-the-blockstore, and the meta-core store-back to itself.
//!
//! Deliberately libp2p-free and blockstore-free — see this crate's `Cargo.toml`.
// `redb::Error` is a large enum; the cache wrappers (`cache.rs`) return it
// directly rather than boxing on every embedded-DB call. Boxing each would be
// pure noise for a single-process store, so silence the lint crate-wide.
#![allow(clippy::result_large_err)]
/// pcs-convention default meta-core root a self-publishing feeder targets when
/// neither its dashboard config nor the `META_CORE_URL` env seed sets one. Keeps
/// the modular storage seam out of compose: a standard install just works; a
/// non-standard meta-core is set in the feeder's config UI. Matches the
/// `metacore-app` service the AppStore feeder apps ship on the shared network.
pub const DEFAULT_META_CORE_URL: &str = "http://metacore-app:9000";
pub mod cache;
pub mod common;
pub mod config;
pub mod enrich;
pub mod filename_meta;
pub mod hash;
pub mod lang;
pub mod meta_core;
pub mod plugin;
pub mod query;
pub mod query_eval;
pub mod serve;
pub mod types;
pub use config::{ConfigField, ConfigSchema, FieldKind};
pub use enrich::{EnrichTarget, EnrichmentConfig, Enricher};
pub use meta_core::FeederStore;
pub use plugin::{
upstream_id_field, ConfigError, FeederPlugin, HashKind, HashOutcome, PluginRegistry,
};
pub use query::{GatewayQuery, GatewaySearchEvent, GatewayWireError, Negation, RangeFilter};
pub use serve::{
configure_plugins, router, serve_feeders, ComputeRequest, ComputeResponse, HashKindDto,
HealthResponse, ManifestResponse, OutcomeDto, PluginManifest, QueryRequest, QueryResponse,
};
pub use types::{ByteStream, DiscoveryId, DiscoveryRecord, GatewayError, Hash, PluginHealth};
-293
View File
@@ -1,293 +0,0 @@
//! Lightweight meta-core write/read client for feeders that **self-publish**.
//!
//! Post-"smart feeder / dumb gateway" split: a feeder given a `META_CORE_URL`
//! writes its own base record into meta-core instead of returning it for the
//! gateway dispatcher to store. This keeps the gateway a pure aggregator +
//! request router; the feeder owns the full record lifecycle for its upstream.
//!
//! The write shape is **byte-for-byte the same** as the gateway's
//! `meta_core::build_metadata_body` (`crates/meta-gateway/src/meta_core.rs`):
//! `cid_*` fields collapse to the `cids/<cid> = "true"` key-set, and a
//! `provenance` JSON blob is stamped. Keeping them identical means a record
//! written by the feeder is indistinguishable from one the dispatcher wrote —
//! the meta-share ingester, meta-watch, and ranking all behave the same.
//!
//! `PUT /api/metadata/{cid}` is merge/upsert (confirmed: meta-core
//! `SetMetadataFlat` sets only the fields in the payload, never deletes), so the
//! feeder's base write and the enrichment plugins' later `PATCH /meta/{cid}`
//! merges accumulate on the same `/file/{cid}` hash.
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use tracing::warn;
use crate::plugin::{HashKind, HashOutcome};
use crate::types::{DiscoveryRecord, Hash};
/// Provenance recorded on every gateway-acquired record. Mirrors the gateway
/// dispatcher's shape so feeder-written and dispatcher-written records are
/// interchangeable.
#[derive(Clone, Debug)]
pub struct Provenance {
pub upstream: String,
pub record_id: String,
/// Stable per-deployment id (the gateway peer this feeder backs, from
/// `META_GATEWAY_PEER_ID`, else the feeder hostname). Analytics group by it.
pub gateway_peer: String,
pub acquired_at: u64,
}
impl Provenance {
/// Build a provenance stamp for `upstream`/`record_id`, stamping "now".
pub fn now(upstream: &str, record_id: &str, gateway_peer: &str) -> Self {
Provenance {
upstream: upstream.to_string(),
record_id: record_id.to_string(),
gateway_peer: gateway_peer.to_string(),
acquired_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
}
}
}
/// JSON-stringified provenance value. Same key set + `"source":"gateway"` as the
/// gateway dispatcher (`crates/meta-gateway/src/meta_core.rs::provenance_to_json`).
pub fn provenance_to_json(p: &Provenance) -> String {
serde_json::to_string(&serde_json::json!({
"source": "gateway",
"upstream": p.upstream,
"recordId": p.record_id,
"gatewayPeer": p.gateway_peer,
"acquiredAt": p.acquired_at,
}))
.expect("static JSON shape — serialisation is infallible")
}
/// Build the flat meta-core PUT body from a [`DiscoveryRecord`] + provenance.
///
/// Identical transform to the gateway dispatcher: any `cid_*` field becomes a
/// `cids/<value> = "true"` key-set member (the CID is self-describing — the
/// algorithm is the multicodec; see METADATA_KEYS.md §2/§14.13), everything else
/// passes through, and `provenance` is stamped last (overriding any plugin-set
/// value).
pub fn build_metadata_body(
record: &DiscoveryRecord,
provenance: &Provenance,
) -> BTreeMap<String, String> {
let mut body: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in &record.fields {
if k.starts_with("cid_") && !v.is_empty() {
body.insert(format!("cids/{v}"), "true".to_string());
} else {
body.insert(k.clone(), v.clone());
}
}
body.insert("provenance".to_string(), provenance_to_json(provenance));
body
}
/// `PUT /api/metadata/{cid}` — write (merge/upsert) the record body into
/// meta-core. `base_url` is the meta-core root (e.g. `http://…-core:9000`).
pub async fn put_record(
http: &reqwest::Client,
base_url: &str,
cid: &str,
body: &BTreeMap<String, String>,
) -> anyhow::Result<()> {
let url = format!("{}/api/metadata/{}", base_url.trim_end_matches('/'), cid);
let resp = http.put(&url).json(body).send().await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("meta-core PUT {url} returned {status}: {text}");
}
Ok(())
}
/// Shape of meta-core's `GET /meta/{cid}` response (`{ hashId, metadata }`).
#[derive(Debug, Deserialize)]
struct MetaGetResponse {
#[serde(default)]
metadata: Option<BTreeMap<String, String>>,
}
/// `GET /meta/{cid}` — read the current flat field map for a record, or `None`
/// when meta-core has no record yet (404). Used to read back a plugin's merged
/// fields (e.g. filename-parser's `originalTitle`/`movieYear`) before handing
/// them to the next plugin in the pipeline.
pub async fn get_record(
http: &reqwest::Client,
base_url: &str,
cid: &str,
) -> anyhow::Result<Option<BTreeMap<String, String>>> {
let url = format!("{}/meta/{}", base_url.trim_end_matches('/'), cid);
let resp = http.get(&url).send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
anyhow::bail!("meta-core GET {url} returned {}", resp.status());
}
let parsed: MetaGetResponse = resp.json().await?;
Ok(parsed.metadata)
}
/// Self-publishing store for feeders that own their record lifecycle (D4):
/// the feeder writes its own **metadata-only** records to meta-core and returns
/// sparse outcomes so the gateway dispatcher skips them; the meta-share ingester
/// Kamilata-propagates them.
///
/// **Byte outcomes are deliberately NOT self-published.** A feeder-side WebDAV
/// write to the gateway core would be seeded by nobody: the gateway core runs
/// with `ENABLE_FILE_WATCHER=false` (so meta-core emits no `/api/events/files`
/// for it, and the ingester's `ipfs_seed` — which seeds only watched files —
/// never sees it), and a sparse outcome makes the gateway skip its own
/// blockstore seed. So byte-bearing outcomes (torznab full-file / subtitles)
/// pass through unchanged and the **gateway** continues to WebDAV-PUT + bitswap-
/// seed them (it's the libp2p/bitswap host). Result: feeder owns the metadata
/// records (the Kamilata-propagated bulk), gateway owns byte delivery — a clean
/// split with no seeding regression. Tribler is metadata-only and uses
/// [`crate::enrich::Enricher`]; this is torznab's record-owning counterpart.
#[derive(Clone)]
pub struct FeederStore {
http: reqwest::Client,
meta_core_url: String,
gateway_peer: String,
}
impl FeederStore {
pub fn new(http: reqwest::Client, meta_core_url: String, gateway_peer: String) -> Self {
FeederStore {
http,
meta_core_url: meta_core_url.trim_end_matches('/').to_string(),
gateway_peer,
}
}
/// Build from the feeder env. `None` when `META_CORE_URL` is unset (the
/// feeder then falls back to the legacy "return outcomes, gateway stores"
/// path). `gateway_peer` ← `META_GATEWAY_PEER_ID` / `HOSTNAME`.
pub fn from_env() -> Option<Self> {
let meta_core_url = env_nonempty("META_CORE_URL")?;
let gateway_peer = env_nonempty("META_GATEWAY_PEER_ID")
.or_else(|| env_nonempty("HOSTNAME"))
.unwrap_or_else(|| "gateway-feeder".to_string());
let http = crate::common::build_http_client(30, "meta-feeder-store", None);
Some(Self::new(http, meta_core_url, gateway_peer))
}
pub fn meta_core_url(&self) -> &str {
&self.meta_core_url
}
/// 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
/// returned unchanged (the gateway stores + seeds the bytes; see the struct
/// docs). On a write failure the original outcome is returned so the gateway
/// stores it (graceful degradation, never lost).
pub async fn publish(
&self,
upstream_id: &str,
record_id: &str,
outcome: HashOutcome,
) -> HashOutcome {
// Only self-publish metadata-only outcomes (bytes None, record Some).
if outcome.bytes.is_some() || outcome.record.is_none() {
return outcome;
}
let cid = outcome.hash.0.clone();
let record = outcome.record.as_ref().expect("record is Some by the guard");
match self
.store_metadata_only(upstream_id, record_id, &cid, record)
.await
{
Ok(()) => sparse(outcome.hash, outcome.hash_kind),
Err(e) => {
warn!(target: "meta-feeder::store", cid = %cid, error = %e,
"metadata self-publish failed; falling back to gateway store");
outcome
}
}
}
/// Metadata-only self-publish: `PUT /api/metadata/{cid}` with provenance.
async fn store_metadata_only(
&self,
upstream_id: &str,
record_id: &str,
cid: &str,
record: &DiscoveryRecord,
) -> anyhow::Result<()> {
let prov = Provenance::now(upstream_id, record_id, &self.gateway_peer);
let body = build_metadata_body(record, &prov);
put_record(&self.http, &self.meta_core_url, cid, &body).await
}
}
fn sparse(hash: Hash, hash_kind: HashKind) -> HashOutcome {
HashOutcome {
hash,
hash_kind,
bytes: None,
record: None,
file_extension: None,
}
}
fn env_nonempty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn rec() -> DiscoveryRecord {
DiscoveryRecord {
upstream_id: "tribler".into(),
record_id: "abc".into(),
fields: BTreeMap::from_iter([
("title".into(), "Big Buck Bunny".into()),
("cid_btih_v1_file".into(), "bafkPERFILE".into()),
]),
}
}
#[test]
fn provenance_shape_matches_gateway() {
let p = Provenance {
upstream: "tribler".into(),
record_id: "abc".into(),
gateway_peer: "metagateway-tribler".into(),
acquired_at: 1_747_504_000,
};
let v: serde_json::Value = serde_json::from_str(&provenance_to_json(&p)).unwrap();
assert_eq!(v["source"], "gateway");
assert_eq!(v["upstream"], "tribler");
assert_eq!(v["recordId"], "abc");
assert_eq!(v["gatewayPeer"], "metagateway-tribler");
assert_eq!(v["acquiredAt"], 1_747_504_000_u64);
}
#[test]
fn body_collapses_cid_fields_and_stamps_provenance() {
let p = Provenance::now("tribler", "abc", "peer");
let body = build_metadata_body(&rec(), &p);
assert_eq!(body.get("title").map(String::as_str), Some("Big Buck Bunny"));
// cid_* collapsed to cids/<value>=true; no cid_* survives.
assert!(body.keys().all(|k| !k.starts_with("cid_")));
assert_eq!(
body.get("cids/bafkPERFILE").map(String::as_str),
Some("true")
);
assert!(body.contains_key("provenance"));
}
}
-223
View File
@@ -1,223 +0,0 @@
//! The `FeederPlugin` trait + supporting types.
//!
//! A feeder plugin bridges one external upstream (`gutenberg`, `arxiv`,
//! `torznab`, …) into the MetaMesh network. It **finds** records and
//! **fetches** bytes; it does NOT hash-into-a-blockstore, store back into
//! meta-core, or speak libp2p — those stay in the gateway core. This is why
//! `FeederPlugin` has no `set_blockstore` / `set_tmdb_budget` hooks (the
//! gateway's `GatewayPlugin` carried them): preview-seeding moves to the core,
//! and a feeder that needs a TMDB budget owns it internally.
//!
//! The `serve_feeders` harness ([`crate::serve`]) wraps any set of
//! `FeederPlugin`s in the feeder HTTP contract; the gateway core's
//! `RemoteFeederPlugin` is the matching HTTP client.
use std::collections::HashMap;
use std::path::Path;
use async_trait::async_trait;
use futures::stream::BoxStream;
// Re-exported so plugins can `use meta_feeder_sdk::plugin::GatewayQuery` (the
// gateway's original `plugin.rs` re-exported it the same way).
pub use crate::query::GatewayQuery;
use crate::query::GatewaySearchEvent;
use crate::types::{ByteStream, DiscoveryRecord, GatewayError, Hash, PluginHealth};
/// One element of what [`FeederPlugin::compute_outcomes`] returns.
///
/// The `hash` is the value shipped to the gateway core. The other fields are
/// signals the core's auto-store uses to persist the content into meta-core.
/// The core's three-branch auto-store routes on `(bytes, record)`:
/// - `(Some, Some)` — full store: WebDAV PUT + metadata PUT with `filePath`.
/// - `(None, Some)` — metadata-only PUT (no local bytes; e.g. torznab samples
/// the middle 1 MiB of a multi-GiB video and surfaces only the record).
/// - `(_, None)` — skip. Cache hits land here.
///
/// `file_extension` MUST be `None` when `bytes` is `None`.
#[derive(Debug)]
pub struct HashOutcome {
pub hash: Hash,
/// Which hash family this CID belongs to. Drives the core's routing —
/// the bitswap blockstore seed only fires for `Sha2_256`.
pub hash_kind: HashKind,
pub bytes: Option<bytes::Bytes>,
pub record: Option<DiscoveryRecord>,
/// File extension to append when naming the WebDAV-side blob (e.g.
/// `"epub"`). No leading dot. MUST be `None` when `bytes` is `None`.
pub file_extension: Option<String>,
}
/// Hash family discriminator on [`HashOutcome`].
///
/// - `Midhash256` — size-prefix-plus-middle-1MB-sample, custom multicodec
/// `0x1000`. Fast, MetaMesh-internal, not retrievable via public IPFS.
/// - `Sha2_256` — standard IPFS CIDv1 over full bytes. Retrievable via bitswap
/// once the bytes are in the core's blockstore. What every shipping plugin
/// today emits.
/// - `BtV1File` — a single file inside a BitTorrent v1 torrent, custom
/// multicodec `0x1001`. Locator, not content hash: opaque to bitswap.
/// - `NzbRelease` — a Usenet release (a Newznab listing), custom multicodec
/// `nzb-release` `0x1005`. Self-describing locator: the cid embeds `{host,
/// id}` in an identity multihash, opaque to bitswap and redeemed to bytes
/// only by a credentialed meta-share peer, which decodes the locator from the
/// cid and grabs the `.nzb` via `t=get` — no KV side-table.
/// - `CardLocator` — the identity of a *work* (a series, a film) as published by
/// a metadata bridge, custom multicodec `0x1007`. The only family with **no
/// bytes behind it at all**: the record IS the payload. Always paired with
/// `bytes: None`, so it lands in the `(None, Some)` metadata-only branch
/// above. Opaque to bitswap, and deliberately so — there is nothing to seed.
/// - `NzbPosting` — a Usenet posting we **scanned ourselves**, custom multicodec
/// `nzb-posting` `0x1003`. A content-derived digest over the article
/// Message-ID set (not a locator: no host is embedded), so any peer with a
/// plain NNTP provider can fetch it — no `IndexerCred`. Opaque to bitswap: the
/// digest is over the id set, not over any block, so the `.nzb` manifest
/// travels separately as an ordinary sha2-256 cid in the record's `manifest`
/// field. Produced by `hash::compute_nzb_posting_cid`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashKind {
/// Reserved: no feeder currently emits midhash256 outcomes, but the family
/// is real across meta-core / meta-share, so the variant stays.
#[allow(dead_code)]
Midhash256,
Sha2_256,
BtV1File,
NzbRelease,
CardLocator,
NzbPosting,
}
/// Startup-only configuration error returned by [`FeederPlugin::configure`].
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("plugin {plugin}: required config missing: {what}")]
MissingConfig {
plugin: &'static str,
/// Human-readable description of the missing setting and where to
/// supply it. Used by the harness's soft-skip warning.
what: &'static str,
},
#[error("plugin {plugin}: cache dir setup failed: {source}")]
CacheSetup {
plugin: &'static str,
#[source]
source: std::io::Error,
},
#[error("plugin {plugin}: {source}")]
Other {
plugin: &'static str,
#[source]
source: anyhow::Error,
},
}
/// Canonical field name for an upstream's stable per-record identifier.
/// Every plugin's `DiscoveryRecord.fields` MUST include this key
/// (`gutenbergid:11`, `tmdbid:31910`, `arxivid:2106.07447`). The rule is
/// `format!("{upstream_id}id")` — lowercase, no separator.
pub fn upstream_id_field(upstream_id: &str) -> String {
format!("{upstream_id}id")
}
/// Static plugin contract. Each enabled `upstream_id` is implemented by
/// exactly one `Box<dyn FeederPlugin>`. Lives behind `&self` in steady state —
/// called concurrently, owns any interior mutability for caches.
///
/// **Field-naming convention.** Every record a plugin returns MUST include the
/// canonical `<upstream_id>id` field — see [`upstream_id_field`].
#[async_trait]
pub trait FeederPlugin: Send + Sync + 'static {
fn upstream_id(&self) -> &'static str;
fn configure(&mut self, cache_dir: &Path) -> Result<(), ConfigError>;
async fn handle_query(
&self,
query: &GatewayQuery,
max_results: usize,
) -> Result<Vec<DiscoveryRecord>, GatewayError>;
/// Streaming variant of [`handle_query`]. The default impl collects
/// [`handle_query`] and replays it as `Base*` + `Done` — correct for every
/// plugin with no incremental enrichment (gutenberg, arxiv, …). torznab
/// overrides it to decouple base records from rate-limited TMDB enrichment.
async fn handle_query_stream(
&self,
query: &GatewayQuery,
max_results: usize,
) -> Result<BoxStream<'static, GatewaySearchEvent>, GatewayError> {
let records = self.handle_query(query, max_results).await?;
let events = records
.into_iter()
.map(GatewaySearchEvent::Base)
.chain(std::iter::once(GatewaySearchEvent::Done));
Ok(Box::pin(futures::stream::iter(events)))
}
/// Resolve `record_id` into one or more content-addressed outcomes.
///
/// - `Err(_)` — the upstream record itself is unresolvable.
/// - `Ok(vec![])` — resolved but yielded zero outcomes.
/// - `Ok(partial_vec)` — bundle-success: per-sibling failures are
/// best-effort (log + drop), surface whatever succeeded.
async fn compute_outcomes(&self, record_id: &str) -> Result<Vec<HashOutcome>, GatewayError>;
async fn handle_fetch(&self, _record_id: &str) -> Result<Option<ByteStream>, GatewayError> {
Ok(None)
}
fn health(&self) -> PluginHealth {
PluginHealth::Ok
}
fn served_file_types(&self) -> &'static [&'static str] {
&[]
}
fn served_content_kinds(&self) -> &'static [&'static str] {
&[]
}
/// Self-describing config schema. The feeder SDK serves this at
/// `GET /config/schema` and renders a generic form from it — the gateway and
/// UI carry no per-plugin field knowledge. Default: no configuration.
fn config_schema(&self) -> crate::config::ConfigSchema {
crate::config::ConfigSchema::default()
}
/// The plugin's current *effective* config as JSON, **unredacted** (secrets
/// included). The SDK redacts it before serving `GET /config/values` and uses
/// it as the merge base on the first save (before any `config.json` exists).
/// Default: empty object. Implementors should mirror the keys in
/// [`config_schema`](FeederPlugin::config_schema).
fn config_values(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn get_blob(&self, _cid: &str) -> Option<Vec<u8>> {
None
}
}
/// Static plugin registry. Built once at startup; read-only thereafter.
/// Keyed by `upstream_id`.
pub type PluginRegistry = HashMap<&'static str, Box<dyn FeederPlugin>>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_error_messages_include_plugin_name() {
let e = ConfigError::MissingConfig {
plugin: "scihub",
what:
"mirrors (set them in the dashboard or gateway-config.json plugins.scihub.mirrors)",
};
let msg = e.to_string();
assert!(msg.contains("scihub"));
assert!(msg.contains("mirrors"));
}
}
-147
View File
@@ -1,147 +0,0 @@
//! Structured query envelope + streaming-search event + wire-error types,
//! shared by feeders (their HTTP contract) and the gateway core (its libp2p
//! wire). These are pure serde data types — no libp2p, no transport.
//!
//! On the core side, `protocol.rs` re-uses these exact structs as the bincode
//! wire shape; the `wire_parity_*` tests there pin the byte encoding. Keep
//! field order / variant order / derive shape stable.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::types::{DiscoveryRecord, GatewayError};
/// Structured query envelope. Gateway plugins do best-effort pushdown to
/// upstream APIs with native query languages (`torznab cat=movie`,
/// `arxiv id_list=`).
///
/// **Authority rule:** `filters` / `ranges` / `negations` are authoritative;
/// `raw_text` is informational. A plugin that consumes both will double-filter
/// and under-match — pick one source per filter; structured wins.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GatewayQuery {
/// Original user text (e.g. `"naruto contentKind:movie imdbid:tt0903747"`).
/// Verbatim — never mutated by the conversion.
pub raw_text: String,
/// Bare-word leaves only, space-joined. The plugin's primary `q=` input
/// for upstream search APIs that take a single search box.
pub free_text: String,
/// Structured filters keyed by field name (raw, unstemmed values).
/// Multiple values per key = OR within a key; AND across keys.
pub filters: BTreeMap<String, Vec<String>>,
/// Numeric range filters (e.g. `movieYear:2020..2024`). Both bounds
/// optional → unbounded on that side.
pub ranges: Vec<RangeFilter>,
/// Negation filters (e.g. `NOT genres:horror`, bare `NOT word`).
pub negations: Vec<Negation>,
}
/// Numeric range filter inside [`GatewayQuery::ranges`]. Both bounds are
/// `Option<i64>` so open-ended ranges drop one bound to `None`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RangeFilter {
pub field: String,
pub lo: Option<i64>,
pub hi: Option<i64>,
}
/// Negation entry inside [`GatewayQuery::negations`]. `field` is `Some(name)`
/// for typed negations (`NOT genres:horror`), `None` for bare-word negation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Negation {
pub field: Option<String>,
pub value: String,
}
impl GatewayQuery {
/// Bare-text input to hand to a free-form upstream search API. Falls back
/// to `"*"` when the conversion produced no free-text leaves so upstreams
/// that 4xx on empty search return their top-ranked results instead.
pub fn free_text_or_star(&self) -> &str {
if self.free_text.trim().is_empty() {
"*"
} else {
self.free_text.as_str()
}
}
/// Construct a free-text-only `GatewayQuery` for in-process use (tests,
/// debug routes). `raw_text` == `free_text`; structured fields stay empty.
pub fn from_free_text(text: impl Into<String>) -> Self {
let text = text.into();
Self {
raw_text: text.clone(),
free_text: text,
filters: BTreeMap::new(),
ranges: Vec::new(),
negations: Vec::new(),
}
}
}
/// One frame in the streaming Search response.
///
/// A plugin emits `Base` records as soon as they're discovered, then
/// best-effort `EnrichPatch`/`Drop` events as enrichment lands, then a
/// terminal `Done`. The default `handle_query_stream` collects `handle_query`
/// and replays it as `Base*` + `Done`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum GatewaySearchEvent {
/// An un-enriched discovery record.
Base(DiscoveryRecord),
/// Incremental enrichment for a previously-sent `Base`, keyed by
/// `record_id`. `set` fields are inserted/overwritten; `remove` deleted.
EnrichPatch {
record_id: String,
set: BTreeMap<String, String>,
remove: Vec<String>,
},
/// Retract a previously-emitted `Base` record entirely.
Drop { record_id: String },
/// Terminal success — no more frames follow.
Done,
/// Terminal error — no more frames follow.
Error(GatewayWireError),
}
/// Wire-stable error shape. Distinct from in-process [`GatewayError`] because
/// adding a variant here is a wire-format change.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
pub enum GatewayWireError {
#[error("not found")]
NotFound,
#[error("rate limited; retry in {retry_after_s}s")]
RateLimited { retry_after_s: u32 },
#[error("upstream transient: {0}")]
UpstreamTransient(String),
#[error("upstream permanent: {0}")]
UpstreamPermanent(String),
/// The protocol was registered but no plugin claims this `upstream_id`.
#[error("plugin not loaded")]
PluginNotLoaded,
/// Internal codec / decode failure on the *receiver* side.
#[error("internal: {0}")]
Internal(String),
}
impl From<GatewayError> for GatewayWireError {
fn from(e: GatewayError) -> Self {
match e {
GatewayError::Transient(s) => GatewayWireError::UpstreamTransient(s),
GatewayError::Permanent(s) => GatewayWireError::UpstreamPermanent(s),
GatewayError::RateLimited { retry_after_s } => {
GatewayWireError::RateLimited { retry_after_s }
}
GatewayError::NotFound => GatewayWireError::NotFound,
// Internal errors are never leaked; map to a transient.
GatewayError::Internal(_) => {
GatewayWireError::UpstreamTransient("internal".to_string())
}
}
}
}
-456
View File
@@ -1,456 +0,0 @@
//! Structured-filter evaluation for gateway records.
//!
//! Two pure helpers consuming the [`crate::query::GatewayQuery`] struct:
//!
//! - [`record_matches`] — does a single record's `fields` map satisfy the
//! query's `filters` + `ranges` + typed `negations`?
//! - [`query_accepts_plugin`] — could a plugin advertising the given
//! `served_file_types` / `served_content_kinds` possibly produce a record
//! that satisfies the query? Used by plugins as a cheap early-return.
//!
//! The dispatcher applies `record_matches` to every plugin's return value, and
//! meta-share applies the same algorithm consumer-side to defend against stale
//! gateway peers — both tiers must agree on outcomes for any (record, query)
//! pair. See the original gateway crate docs for the full semantics.
use std::collections::BTreeMap;
use crate::query::GatewayQuery;
/// True iff a record's `fields` satisfies every filter, range, and typed
/// negation in `query`.
pub fn record_matches(fields: &BTreeMap<String, String>, query: &GatewayQuery) -> bool {
for (key, allowed) in &query.filters {
if key == "languages" {
if !languages_filter_matches(fields, allowed) {
return false;
}
continue;
}
let Some(field_value) = fields.get(key) else {
return false;
};
if !any_csv_value_matches(field_value, allowed) {
return false;
}
}
for range in &query.ranges {
let Some(field_value) = fields.get(&range.field) else {
return false;
};
let Ok(n) = field_value.parse::<i64>() else {
return false;
};
if let Some(lo) = range.lo {
if n < lo {
return false;
}
}
if let Some(hi) = range.hi {
if n > hi {
return false;
}
}
}
for negation in &query.negations {
let Some(name) = negation.field.as_deref() else {
// Bare-word negation — not enforced here.
continue;
};
let Some(field_value) = fields.get(name) else {
// Missing field ⇒ negation passes (nothing to exclude).
continue;
};
if any_csv_value_matches(field_value, std::slice::from_ref(&negation.value)) {
return false;
}
}
true
}
/// True iff a plugin advertising the given `served_file_types` /
/// `served_content_kinds` could possibly produce a record satisfying
/// `query.filters["fileType" | "contentKind"]`.
pub fn query_accepts_plugin(
query: &GatewayQuery,
served_file_types: &[&str],
served_content_kinds: &[&str],
) -> bool {
accepts_axis(query.filters.get("fileType"), served_file_types)
&& accepts_axis(query.filters.get("contentKind"), served_content_kinds)
}
fn accepts_axis(requested: Option<&Vec<String>>, served: &[&str]) -> bool {
let Some(requested) = requested else {
return true;
};
if requested.is_empty() {
return true;
}
// Wildcard: a plugin that advertises `"*"` on an axis serves every value on
// that axis (e.g. tribler is a free-text overlay search with no inherent
// type limitation). It accepts any requested fileType/contentKind.
if served.contains(&"*") {
return true;
}
requested
.iter()
.any(|v| served.iter().any(|s| s.eq_ignore_ascii_case(v)))
}
/// Resolve a `languages` filter against the key-set storage form
/// (`languages/<lang3> = "true"`). **Fails open** on anything we can't
/// confidently exclude. A record matches when:
/// 1. it carries a requested code as a truthy key-set member (OR across codes);
/// 2. a legacy flat `languages` csv field contains a requested code;
/// 3. it carries `mul` (multiple — may contain a requested one) or `und`
/// (undetermined); or
/// 4. it carries no language information at all.
///
/// Only a record with a *concrete*, non-matching language (e.g. `jpn`-only
/// under an `eng` filter) is dropped. Kept byte-identical in logic with
/// meta-search's copy (and the client's `passesLangFilter`) — language narrows
/// the result set without ever hiding unknown/undetermined/multi releases.
fn languages_filter_matches(fields: &BTreeMap<String, String>, allowed: &[String]) -> bool {
for code in allowed {
let key = format!("languages/{}", code.trim().to_ascii_lowercase());
if fields.get(&key).is_some_and(|v| is_truthy_member(v)) {
return true;
}
}
if let Some(flat) = fields.get("languages") {
if any_csv_value_matches(flat, allowed) {
return true;
}
}
// Fail open: `mul`/`und` are explicit "could be anything / unknown" markers,
// and a record with no concrete language at all is equally unknown. Keep all
// three; only a specific, non-matching language reaches the `false` below.
if fields.get("languages/mul").is_some_and(|v| is_truthy_member(v))
|| fields.get("languages/und").is_some_and(|v| is_truthy_member(v))
{
return true;
}
!record_has_concrete_language(fields)
}
/// True iff the record states a *concrete* language: a truthy `languages/<code>`
/// key-set member other than the `und`/`mul` non-codes, or a non-empty legacy
/// flat `languages` field. Distinguishes "a specific language that didn't match
/// ⇒ drop" from "no language info ⇒ keep" for [`languages_filter_matches`].
fn record_has_concrete_language(fields: &BTreeMap<String, String>) -> bool {
if fields.get("languages").is_some_and(|v| !v.trim().is_empty()) {
return true;
}
fields.iter().any(|(k, v)| {
is_truthy_member(v)
&& k.starts_with("languages/")
&& k != "languages/und"
&& k != "languages/mul"
})
}
/// Key-set members are written as the literal `"true"`; tolerate a couple of
/// obvious truthy spellings and treat anything else as absent.
fn is_truthy_member(v: &str) -> bool {
matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes")
}
/// True iff `field_value` (treated as a csv set when it contains commas)
/// contains at least one case-insensitive match for any item in `allowed`.
fn any_csv_value_matches(field_value: &str, allowed: &[String]) -> bool {
for piece in field_value.split(',') {
let piece = piece.trim();
if piece.is_empty() {
continue;
}
if allowed.iter().any(|v| v.eq_ignore_ascii_case(piece)) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::{Negation, RangeFilter};
fn empty_query() -> GatewayQuery {
GatewayQuery {
raw_text: String::new(),
free_text: String::new(),
filters: BTreeMap::new(),
ranges: Vec::new(),
negations: Vec::new(),
}
}
fn with_filter(field: &str, values: &[&str]) -> GatewayQuery {
let mut q = empty_query();
q.filters.insert(
field.to_string(),
values.iter().map(|s| s.to_string()).collect(),
);
q
}
fn fields(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn filter_rejects_mismatched_filetype() {
let q = with_filter("fileType", &["video"]);
assert!(!record_matches(&fields(&[("fileType", "image")]), &q));
}
#[test]
fn filter_accepts_matching_filetype() {
let q = with_filter("fileType", &["video"]);
assert!(record_matches(&fields(&[("fileType", "video")]), &q));
}
#[test]
fn filter_rejects_record_missing_field() {
let q = with_filter("fileType", &["video"]);
assert!(!record_matches(&fields(&[]), &q));
}
#[test]
fn filter_is_case_insensitive() {
let q = with_filter("fileType", &["VIDEO"]);
assert!(record_matches(&fields(&[("fileType", "video")]), &q));
let q = with_filter("fileType", &["video"]);
assert!(record_matches(&fields(&[("fileType", "VIDEO")]), &q));
}
#[test]
fn filter_ors_within_key() {
let q = with_filter("fileType", &["video", "audio"]);
assert!(record_matches(&fields(&[("fileType", "audio")]), &q));
assert!(record_matches(&fields(&[("fileType", "video")]), &q));
assert!(!record_matches(&fields(&[("fileType", "image")]), &q));
}
#[test]
fn filter_ands_across_keys() {
let mut q = with_filter("fileType", &["video"]);
q.filters.insert("genres".into(), vec!["Action".into()]);
assert!(!record_matches(
&fields(&[("fileType", "video"), ("genres", "Drama")]),
&q
));
assert!(record_matches(
&fields(&[("fileType", "video"), ("genres", "Action")]),
&q
));
}
#[test]
fn csv_field_matches_one_of_its_values() {
let q = with_filter("genres", &["Anime"]);
assert!(record_matches(
&fields(&[("genres", "Animation,Anime,Drama")]),
&q
));
}
#[test]
fn csv_field_with_whitespace_is_trimmed() {
let q = with_filter("genres", &["Anime"]);
assert!(record_matches(
&fields(&[("genres", "Animation, Anime , Drama")]),
&q
));
}
#[test]
fn languages_keyset_member_matches() {
let q = with_filter("languages", &["eng"]);
assert!(record_matches(&fields(&[("languages/eng", "true")]), &q));
}
#[test]
fn languages_filter_is_or_across_codes() {
let q = with_filter("languages", &["eng", "fre"]);
assert!(record_matches(&fields(&[("languages/eng", "true")]), &q));
assert!(record_matches(&fields(&[("languages/fre", "true")]), &q));
assert!(!record_matches(&fields(&[("languages/jpn", "true")]), &q));
}
#[test]
fn languages_filter_fails_open_on_unknown() {
let q = with_filter("languages", &["eng"]);
// No language info at all ⇒ kept (unknown, not excluded).
assert!(record_matches(&fields(&[("fileType", "video")]), &q));
// `mul` (multi) may contain the requested language ⇒ kept.
assert!(record_matches(&fields(&[("languages/mul", "true")]), &q));
// `und` (undetermined) ⇒ kept.
assert!(record_matches(&fields(&[("languages/und", "true")]), &q));
// A specific language alongside `mul` is still kept (multi).
assert!(record_matches(
&fields(&[("languages/jpn", "true"), ("languages/mul", "true")]),
&q
));
}
#[test]
fn languages_concrete_mismatch_still_drops() {
let q = with_filter("languages", &["eng"]);
// A concrete, non-matching language ⇒ dropped (language narrows).
assert!(!record_matches(&fields(&[("languages/jpn", "true")]), &q));
// A tombstoned/false member is treated as absent ⇒ unknown ⇒ kept.
assert!(record_matches(&fields(&[("languages/eng", "false")]), &q));
}
#[test]
fn languages_legacy_flat_csv_fallback() {
let q = with_filter("languages", &["eng"]);
assert!(record_matches(&fields(&[("languages", "jpn,eng")]), &q));
assert!(!record_matches(&fields(&[("languages", "jpn,ger")]), &q));
}
#[test]
fn range_in_bounds() {
let mut q = empty_query();
q.ranges.push(RangeFilter {
field: "movieYear".into(),
lo: Some(2010),
hi: Some(2020),
});
assert!(record_matches(&fields(&[("movieYear", "2015")]), &q));
}
#[test]
fn range_out_of_bounds_rejected() {
let mut q = empty_query();
q.ranges.push(RangeFilter {
field: "movieYear".into(),
lo: Some(2010),
hi: Some(2020),
});
assert!(!record_matches(&fields(&[("movieYear", "2025")]), &q));
assert!(!record_matches(&fields(&[("movieYear", "2005")]), &q));
}
#[test]
fn range_open_upper_bound() {
let mut q = empty_query();
q.ranges.push(RangeFilter {
field: "movieYear".into(),
lo: Some(2010),
hi: None,
});
assert!(record_matches(&fields(&[("movieYear", "2025")]), &q));
}
#[test]
fn range_unparseable_value_rejected() {
let mut q = empty_query();
q.ranges.push(RangeFilter {
field: "movieYear".into(),
lo: Some(2010),
hi: None,
});
assert!(!record_matches(&fields(&[("movieYear", "circa 2015")]), &q));
}
#[test]
fn typed_negation_excludes_match() {
let mut q = empty_query();
q.negations.push(Negation {
field: Some("fileType".into()),
value: "image".into(),
});
assert!(!record_matches(&fields(&[("fileType", "image")]), &q));
assert!(record_matches(&fields(&[("fileType", "video")]), &q));
}
#[test]
fn typed_negation_passes_when_field_missing() {
let mut q = empty_query();
q.negations.push(Negation {
field: Some("fileType".into()),
value: "image".into(),
});
assert!(record_matches(&fields(&[]), &q));
}
#[test]
fn bare_word_negation_is_not_enforced() {
let mut q = empty_query();
q.negations.push(Negation {
field: None,
value: "alice".into(),
});
assert!(record_matches(
&fields(&[("title", "alice in wonderland")]),
&q
));
}
#[test]
fn empty_query_matches_anything() {
assert!(record_matches(&fields(&[]), &empty_query()));
assert!(record_matches(
&fields(&[("fileType", "video"), ("title", "x")]),
&empty_query()
));
}
#[test]
fn accepts_returns_true_when_no_type_filter() {
let q = empty_query();
assert!(query_accepts_plugin(&q, &["video"], &["movie"]));
assert!(query_accepts_plugin(&q, &[], &[]));
}
#[test]
fn accepts_rejects_disjoint_filetype() {
let q = with_filter("fileType", &["video"]);
assert!(!query_accepts_plugin(&q, &["image"], &["gif"]));
assert!(query_accepts_plugin(&q, &["video"], &[]));
}
#[test]
fn accepts_rejects_disjoint_contentkind() {
let q = with_filter("contentKind", &["movie"]);
assert!(!query_accepts_plugin(
&q,
&["video", "image"],
&["paper", "book"]
));
assert!(query_accepts_plugin(&q, &["video"], &["movie", "episode"]));
}
#[test]
fn accepts_requires_both_axes_when_both_filtered() {
let mut q = with_filter("fileType", &["video"]);
q.filters.insert("contentKind".into(), vec!["movie".into()]);
assert!(query_accepts_plugin(&q, &["video"], &["movie"]));
assert!(!query_accepts_plugin(&q, &["video"], &["paper"]));
assert!(!query_accepts_plugin(&q, &["image"], &["movie"]));
}
#[test]
fn accepts_wildcard_matches_any_requested_type() {
// A plugin advertising "*" (e.g. tribler) accepts any fileType /
// contentKind filter on the wildcarded axis.
let q = with_filter("fileType", &["archive"]);
assert!(query_accepts_plugin(&q, &["*"], &[]));
let q = with_filter("contentKind", &["audiobook"]);
assert!(query_accepts_plugin(&q, &["*"], &["*"]));
// Wildcard on one axis only: the non-wildcard axis still gates.
let mut q = with_filter("fileType", &["audio"]);
q.filters.insert("contentKind".into(), vec!["podcast".into()]);
assert!(query_accepts_plugin(&q, &["*"], &["*"]));
assert!(!query_accepts_plugin(&q, &["*"], &["movie"]));
}
}
-759
View File
@@ -1,759 +0,0 @@
//! `serve_feeders` — the axum harness that exposes a set of [`FeederPlugin`]s
//! over the **feeder HTTP contract**. Every feeder sidecar binary is just
//! `serve_feeders(vec![MyPlugin::new()], state_dir, listen)`.
//!
//! # Contract (JSON over HTTP, mirrors meta-sort's container-plugin shape)
//!
//! - `GET /manifest` → [`ManifestResponse`]
//! - `GET /health` → [`HealthResponse`]
//! - `POST /query` → [`QueryRequest`] → [`QueryResponse`]
//! - `POST /compute` → [`ComputeRequest`] → [`ComputeResponse`]
//! - `GET /fetch/{upstream_id}/{record_id}` → streamed bytes (or 404)
//! - `GET /blob/{upstream_id}/{cid}` → bytes (or 404)
//!
//! ## Bytes & the v1 simplification
//!
//! `/compute` returns each outcome's bytes **inline, base64-encoded**
//! ([`OutcomeDto::bytes_b64`]) — this reuses every existing `compute_outcomes`
//! impl verbatim (they already return bytes in `HashOutcome.bytes`) and keeps
//! the gateway core's auto-store a single round-trip. base64 adds ~33%; fine
//! for books/papers/posters. Large-file streaming (torznab multi-GiB) is a
//! documented follow-up — those outcomes are metadata-only anyway
//! (`bytes: None`), so they never ride this path.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use axum::{
body::Body,
extract::{Path, State},
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use base64::Engine as _;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::{info, warn};
use crate::config::{self, CONFIG_PAGE_HTML};
use crate::plugin::{ConfigError, FeederPlugin, HashKind, HashOutcome};
use crate::query::{GatewayQuery, GatewaySearchEvent, GatewayWireError};
use crate::types::{DiscoveryRecord, GatewayError, PluginHealth};
// -------- wire DTOs --------------------------------------------------------
/// Serializable mirror of [`HashKind`] for the feeder HTTP boundary.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "snake_case")]
pub enum HashKindDto {
Midhash256,
Sha2_256,
BtV1File,
NzbRelease,
CardLocator,
NzbPosting,
}
impl From<HashKind> for HashKindDto {
fn from(k: HashKind) -> Self {
match k {
HashKind::Midhash256 => HashKindDto::Midhash256,
HashKind::Sha2_256 => HashKindDto::Sha2_256,
HashKind::BtV1File => HashKindDto::BtV1File,
HashKind::NzbRelease => HashKindDto::NzbRelease,
HashKind::CardLocator => HashKindDto::CardLocator,
HashKind::NzbPosting => HashKindDto::NzbPosting,
}
}
}
impl From<HashKindDto> for HashKind {
fn from(k: HashKindDto) -> Self {
match k {
HashKindDto::Midhash256 => HashKind::Midhash256,
HashKindDto::Sha2_256 => HashKind::Sha2_256,
HashKindDto::BtV1File => HashKind::BtV1File,
HashKindDto::NzbRelease => HashKind::NzbRelease,
HashKindDto::CardLocator => HashKind::CardLocator,
HashKindDto::NzbPosting => HashKind::NzbPosting,
}
}
}
/// One plugin's capability surface in [`ManifestResponse`].
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PluginManifest {
pub id: String,
pub served_file_types: Vec<String>,
pub served_content_kinds: Vec<String>,
}
/// `GET /manifest` — the feeder's hosted plugins + their served types. The
/// gateway core reads this to register `RemoteFeederPlugin`s and to build the
/// capability heartbeat it broadcasts to meta-share.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ManifestResponse {
pub feeder_version: String,
pub plugins: Vec<PluginManifest>,
}
/// One plugin's liveness in [`HealthResponse`].
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PluginHealthEntry {
pub id: String,
pub health: PluginHealth,
}
/// `GET /health` — `status` is `"ok"` iff every hosted plugin reports `Ok`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct HealthResponse {
pub status: String,
pub plugins: Vec<PluginHealthEntry>,
}
/// `POST /query` request body.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct QueryRequest {
pub upstream_id: String,
pub query: GatewayQuery,
pub max_results: u32,
}
/// `POST /query` response body.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct QueryResponse {
pub records: Vec<DiscoveryRecord>,
}
/// `POST /compute` request body.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ComputeRequest {
pub upstream_id: String,
pub record_id: String,
}
/// One resolved outcome on the feeder wire. `bytes_b64` carries the full
/// payload base64-encoded when the plugin fetched bytes (`Sha2_256` full
/// store); `None` for metadata-only outcomes (the core's `(None, Some)`
/// branch — e.g. torznab midhash).
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct OutcomeDto {
pub hash: String,
pub hash_kind: HashKindDto,
pub file_extension: Option<String>,
pub record: Option<DiscoveryRecord>,
pub bytes_b64: Option<String>,
}
impl OutcomeDto {
fn from_outcome(o: HashOutcome) -> Self {
OutcomeDto {
hash: o.hash.0,
hash_kind: o.hash_kind.into(),
file_extension: o.file_extension,
record: o.record,
bytes_b64: o
.bytes
.map(|b| base64::engine::general_purpose::STANDARD.encode(&b)),
}
}
}
/// `POST /compute` response body.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ComputeResponse {
pub outcomes: Vec<OutcomeDto>,
}
// -------- app state + error mapping ----------------------------------------
#[derive(Clone)]
struct AppState {
plugins: Arc<HashMap<String, Arc<dyn FeederPlugin>>>,
version: String,
/// Feeder state dir. Per-plugin config persists at
/// `state_dir/gateway/<id>/config.json` — the same per-plugin dir handed to
/// `configure()`, so a UI save is read back on the next restart.
state_dir: PathBuf,
}
impl AppState {
fn plugin(&self, upstream_id: &str) -> Result<&Arc<dyn FeederPlugin>, Response> {
self.plugins.get(upstream_id).ok_or_else(|| {
(
StatusCode::NOT_FOUND,
format!("no feeder plugin for upstream_id '{upstream_id}'"),
)
.into_response()
})
}
/// `state_dir/gateway/<id>/config.json` — canonical per-plugin config file.
fn config_path(&self, upstream_id: &str) -> PathBuf {
self.state_dir
.join("gateway")
.join(upstream_id)
.join("config.json")
}
/// The effective config as JSON: the persisted `config.json` if it exists,
/// else the plugin's in-memory (env-seeded) config. Used as the redaction
/// source for reads and the merge base for writes.
fn effective_config(&self, upstream_id: &str) -> Value {
if let Ok(bytes) = std::fs::read(self.config_path(upstream_id)) {
if let Ok(v) = serde_json::from_slice::<Value>(&bytes) {
return v;
}
}
self.plugins
.get(upstream_id)
.map(|p| p.config_values())
.unwrap_or_else(|| serde_json::json!({}))
}
}
/// Map an in-process [`GatewayError`] to an HTTP status + message. Mirrors the
/// status semantics the gateway's `RemoteFeederPlugin` will translate back
/// into a `GatewayError` on the core side.
fn gateway_error_response(e: GatewayError) -> Response {
let (status, msg) = match e {
GatewayError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
GatewayError::RateLimited { retry_after_s } => (
StatusCode::TOO_MANY_REQUESTS,
format!("rate limited; retry in {retry_after_s}s"),
),
GatewayError::Transient(s) => (StatusCode::BAD_GATEWAY, s),
GatewayError::Permanent(s) => (StatusCode::UNPROCESSABLE_ENTITY, s),
GatewayError::Internal(s) => (StatusCode::INTERNAL_SERVER_ERROR, s.to_string()),
};
(status, msg).into_response()
}
// -------- handlers ---------------------------------------------------------
async fn manifest(State(state): State<AppState>) -> Json<ManifestResponse> {
let mut plugins: Vec<PluginManifest> = state
.plugins
.values()
.map(|p| PluginManifest {
id: p.upstream_id().to_string(),
served_file_types: p.served_file_types().iter().map(|s| s.to_string()).collect(),
served_content_kinds: p
.served_content_kinds()
.iter()
.map(|s| s.to_string())
.collect(),
})
.collect();
plugins.sort_by(|a, b| a.id.cmp(&b.id));
Json(ManifestResponse {
feeder_version: state.version.clone(),
plugins,
})
}
async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
let mut entries: Vec<PluginHealthEntry> = state
.plugins
.values()
.map(|p| PluginHealthEntry {
id: p.upstream_id().to_string(),
health: p.health(),
})
.collect();
entries.sort_by(|a, b| a.id.cmp(&b.id));
let all_ok = entries
.iter()
.all(|e| matches!(e.health, PluginHealth::Ok));
Json(HealthResponse {
status: if all_ok { "ok" } else { "degraded" }.to_string(),
plugins: entries,
})
}
async fn query(
State(state): State<AppState>,
Json(req): Json<QueryRequest>,
) -> Result<Json<QueryResponse>, Response> {
let plugin = state.plugin(&req.upstream_id)?;
let records = plugin
.handle_query(&req.query, req.max_results as usize)
.await
.map_err(gateway_error_response)?;
Ok(Json(QueryResponse { records }))
}
/// `POST /query_stream` — the streaming analog of [`query`]. Emits the plugin's
/// [`FeederPlugin::handle_query_stream`] events as **NDJSON**: one JSON-encoded
/// [`GatewaySearchEvent`] per line (`application/x-ndjson`). The gateway core's
/// `RemoteFeederPlugin` consumes this and re-emits a `BoxStream<GatewaySearchEvent>`
/// into the libp2p streaming worker, preserving incremental enrichment (e.g.
/// torznab's late TMDB / poster / subtitle-availability patches). A plugin that
/// doesn't override `handle_query_stream` still works here — the trait default
/// replays `handle_query` as `Base* + Done`.
async fn query_stream(
State(state): State<AppState>,
Json(req): Json<QueryRequest>,
) -> Result<Response, Response> {
let plugin = state.plugin(&req.upstream_id)?;
// The open can fail (e.g. the plugin can't reach its upstream at all); that
// maps to an HTTP error status, same as `/query`. Per-event errors instead
// ride the stream as a terminal `GatewaySearchEvent::Error` line.
let events = plugin
.handle_query_stream(&req.query, req.max_results as usize)
.await
.map_err(gateway_error_response)?;
let body = events.map(|ev| {
// Serialization of GatewaySearchEvent is infallible in practice; on the
// off chance it fails, terminate with an Error line so the consumer sees
// a clean terminal failure rather than a truncated stream.
let mut line = serde_json::to_vec(&ev).unwrap_or_else(|e| {
serde_json::to_vec(&GatewaySearchEvent::Error(GatewayWireError::Internal(
format!("event serialize: {e}"),
)))
.unwrap_or_default()
});
line.push(b'\n');
Ok::<_, std::convert::Infallible>(line)
});
Ok((
[(axum::http::header::CONTENT_TYPE, "application/x-ndjson")],
Body::from_stream(body),
)
.into_response())
}
async fn compute(
State(state): State<AppState>,
Json(req): Json<ComputeRequest>,
) -> Result<Json<ComputeResponse>, Response> {
let plugin = state.plugin(&req.upstream_id)?;
let outcomes = plugin
.compute_outcomes(&req.record_id)
.await
.map_err(gateway_error_response)?;
Ok(Json(ComputeResponse {
outcomes: outcomes.into_iter().map(OutcomeDto::from_outcome).collect(),
}))
}
async fn fetch(
State(state): State<AppState>,
Path((upstream_id, record_id)): Path<(String, String)>,
) -> Result<Response, Response> {
let plugin = state.plugin(&upstream_id)?;
match plugin
.handle_fetch(&record_id)
.await
.map_err(gateway_error_response)?
{
Some(stream) => {
// Map the plugin's GatewayError stream into an io::Error stream so
// axum's Body::from_stream accepts it.
let body_stream = stream.map(|chunk| {
chunk.map_err(|e| std::io::Error::other(e.to_string()))
});
Ok(Body::from_stream(body_stream).into_response())
}
None => Err((StatusCode::NOT_FOUND, "no bytes for record").into_response()),
}
}
async fn blob(
State(state): State<AppState>,
Path((upstream_id, cid)): Path<(String, String)>,
) -> Result<Response, Response> {
let plugin = state.plugin(&upstream_id)?;
match plugin.get_blob(&cid).await {
Some(bytes) => Ok(bytes.into_response()),
None => Err((StatusCode::NOT_FOUND, "no such blob").into_response()),
}
}
// -------- config plane -----------------------------------------------------
//
// Generic, schema-driven per-plugin config. The page + the schema/values JSON
// are all served relative to the request path, so this works identically when
// hit directly on the feeder or reverse-proxied through the gateway.
/// `GET /config` — the schema-driven HTML form (static, plugin-agnostic).
async fn config_page() -> Html<&'static str> {
Html(CONFIG_PAGE_HTML)
}
/// `GET /config/schema` — the single hosted plugin's declared config schema.
/// (A feeder hosts one plugin in practice; if it hosts several, the first by
/// id is used — the gateway proxies per upstream_id anyway.)
async fn config_schema(State(state): State<AppState>) -> Json<config::ConfigSchema> {
let schema = sole_plugin(&state)
.map(|p| p.config_schema())
.unwrap_or_default();
Json(schema)
}
/// `GET /config/values` — current values with secrets redacted to `<key>_set`.
async fn config_values_get(State(state): State<AppState>) -> Json<Value> {
match sole_plugin(&state) {
Some(p) => {
let schema = p.config_schema();
let eff = state.effective_config(p.upstream_id());
Json(config::redact(&eff, &schema))
}
None => Json(serde_json::json!({})),
}
}
/// `PUT /config/values` — merge the submitted values onto the stored config
/// (schema-aware; blank secrets keep their stored value), persist atomically,
/// and signal that a feeder restart is needed to apply them.
async fn config_values_put(
State(state): State<AppState>,
Json(incoming): Json<Value>,
) -> Result<Json<Value>, Response> {
let plugin = sole_plugin(&state)
.ok_or_else(|| (StatusCode::NOT_FOUND, "feeder hosts no plugin").into_response())?;
let id = plugin.upstream_id();
let schema = plugin.config_schema();
let base = state.effective_config(id);
let merged = config::merge(&base, &incoming, &schema);
let path = state.config_path(id);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("mkdir: {e}")).into_response())?;
}
let tmp = path.with_extension("json.tmp");
let body = serde_json::to_vec_pretty(&merged)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("serialize: {e}")).into_response())?;
std::fs::write(&tmp, body)
.and_then(|_| std::fs::rename(&tmp, &path))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("write config: {e}")).into_response())?;
info!(target: "meta-feeder", upstream_id = id, path = %path.display(), "feeder config saved; scheduling self-restart to apply");
// Apply the new config by restarting the process. We can't hot-swap config
// in-place: plugins hold an exclusive redb cache handle for their whole life,
// so a fresh instance can't reopen the cache while the old one is alive. A
// clean process exit is the reliable apply path — Docker's restart policy
// (`unless-stopped`) brings the feeder back in ~2-3s and `configure()`
// re-reads config.json at startup. Delay briefly so THIS response flushes to
// the client before we exit.
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
info!(target: "meta-feeder", "restarting now to apply saved config");
std::process::exit(0);
});
Ok(Json(serde_json::json!({ "saved": true, "reloading": true })))
}
/// `POST /enrich/callback` — sink for the enrichment plugins' completion
/// callbacks. The feeder drives those plugins (filename-parser, tmdb) during a
/// self-published resolve and reads results back by polling meta-core, so the
/// callback carries no load — this route exists only so the plugins' callback
/// POST gets a 200 instead of logging a connection failure. Body is ignored.
async fn enrich_callback() -> StatusCode {
StatusCode::OK
}
/// A feeder hosts one plugin in the common case. Pick it (lowest id for
/// determinism if several are present).
fn sole_plugin(state: &AppState) -> Option<&Arc<dyn FeederPlugin>> {
state
.plugins
.values()
.min_by(|a, b| a.upstream_id().cmp(b.upstream_id()))
}
// -------- entry point ------------------------------------------------------
/// Build the feeder router for an already-configured plugin set. Exposed for
/// in-process tests (drive it without binding a socket).
pub fn router(
plugins: HashMap<String, Arc<dyn FeederPlugin>>,
version: String,
state_dir: impl Into<PathBuf>,
) -> Router {
let state = AppState {
plugins: Arc::new(plugins),
version,
state_dir: state_dir.into(),
};
Router::new()
.route("/manifest", get(manifest))
.route("/health", get(health))
.route("/query", post(query))
.route("/query_stream", post(query_stream))
.route("/compute", post(compute))
.route("/fetch/:upstream_id/:record_id", get(fetch))
.route("/blob/:upstream_id/:cid", get(blob))
.route("/config", get(config_page))
.route("/config/schema", get(config_schema))
.route("/config/values", get(config_values_get).put(config_values_put))
.route("/enrich/callback", post(enrich_callback))
.with_state(state)
}
/// Configure each plugin (cache dir under `state_dir/gateway/<id>`), then serve
/// the feeder HTTP contract on `listen` until shutdown.
///
/// The feeder process never aborts on a config problem: a plugin whose
/// `configure()` returns [`ConfigError::MissingConfig`] is **soft-skipped**
/// (warn + dropped — an optional source the operator didn't ask for), and any
/// other failure (bad/empty key, cache-dir setup, …) registers a
/// [`DegradedPlugin`] that stays up and reports the reason via `/health` and
/// `/manifest`. The container keeps running so the error is visible in the UX
/// instead of crash-looping.
pub async fn serve_feeders(
plugins: Vec<Box<dyn FeederPlugin>>,
state_dir: impl Into<PathBuf>,
listen: SocketAddr,
) -> anyhow::Result<()> {
let state_dir = state_dir.into();
let configured = configure_plugins(plugins, &state_dir)?;
let version = env!("CARGO_PKG_VERSION").to_string();
let app = router(configured, version, state_dir.clone());
let listener = tokio::net::TcpListener::bind(listen).await?;
info!(
target: "meta-feeder",
%listen,
"feeder listening"
);
axum::serve(listener, app)
.await
.map_err(anyhow::Error::from)
}
/// A plugin whose `configure()` (or cache-dir setup) failed. It stays
/// **registered** so the failure is visible at `GET /health` (as
/// [`PluginHealth::Degraded`]) and `GET /manifest`; any query/compute against it
/// returns the captured reason as a permanent error. This is what keeps one
/// misconfigured feeder from crash-looping the whole container — the operator
/// sees the reason in the UX and fixes the config, no restart-loop required.
struct DegradedPlugin {
id: &'static str,
reason: String,
file_types: &'static [&'static str],
content_kinds: &'static [&'static str],
/// Preserved from the wrapped plugin so a degraded/unconfigured feeder still
/// serves its config form — otherwise the dashboard shows "no configuration
/// required" and the operator can never fix the very config that's missing.
schema: crate::config::ConfigSchema,
}
#[async_trait::async_trait]
impl FeederPlugin for DegradedPlugin {
fn upstream_id(&self) -> &'static str {
self.id
}
fn configure(&mut self, _cache_dir: &FsPath) -> Result<(), ConfigError> {
Ok(())
}
async fn handle_query(
&self,
_query: &GatewayQuery,
_max_results: usize,
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
Err(GatewayError::Permanent(self.reason.clone()))
}
async fn compute_outcomes(
&self,
_record_id: &str,
) -> Result<Vec<HashOutcome>, GatewayError> {
Err(GatewayError::Permanent(self.reason.clone()))
}
fn health(&self) -> PluginHealth {
PluginHealth::Degraded {
reason: self.reason.clone(),
}
}
fn served_file_types(&self) -> &'static [&'static str] {
self.file_types
}
fn served_content_kinds(&self) -> &'static [&'static str] {
self.content_kinds
}
fn config_schema(&self) -> crate::config::ConfigSchema {
self.schema.clone()
}
}
/// Run each plugin's `configure()` against its per-plugin cache dir and return
/// the plugins keyed by `upstream_id`. Never errors on a per-plugin problem:
/// `MissingConfig` is soft-skipped, every other failure becomes a
/// [`DegradedPlugin`] (see [`serve_feeders`]). Public so feeder binaries /
/// tests can configure without serving.
pub fn configure_plugins(
plugins: Vec<Box<dyn FeederPlugin>>,
state_dir: &FsPath,
) -> anyhow::Result<HashMap<String, Arc<dyn FeederPlugin>>> {
let mut out: HashMap<String, Arc<dyn FeederPlugin>> = HashMap::new();
for mut plugin in plugins {
let id = plugin.upstream_id();
// Captured up front (both are `'static`, so this borrow of `plugin`
// ends immediately) — lets us build a DegradedPlugin without holding a
// borrow across `configure(&mut self)` / the move into `out`.
let file_types = plugin.served_file_types();
let content_kinds = plugin.served_content_kinds();
// Preserve the config surface so a degraded stand-in still serves the
// real config form (see DegradedPlugin.schema).
let schema = plugin.config_schema();
let cache_dir = state_dir.join("gateway").join(id);
// Build (don't insert) a degraded stand-in: keeps the container up and
// surfaces the reason via /health instead of crash-looping.
let make_degraded = |reason: String| -> Arc<dyn FeederPlugin> {
warn!(
target: "meta-feeder",
upstream_id = id,
%reason,
"feeder plugin degraded: container stays up; error surfaced via /health"
);
Arc::new(DegradedPlugin {
id,
reason,
file_types,
content_kinds,
schema: schema.clone(),
})
};
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
let reason = format!("cache dir {} setup failed: {e}", cache_dir.display());
out.insert(id.to_string(), make_degraded(reason));
continue;
}
match plugin.configure(&cache_dir) {
Ok(()) => {
out.insert(id.to_string(), Arc::from(plugin));
}
Err(ConfigError::MissingConfig { plugin: p, what }) => {
// Keep a degraded stand-in (don't drop it) so its config form
// stays reachable from the dashboard and the operator can supply
// the missing config — then restart the feeder to apply it.
warn!(
target: "meta-feeder",
upstream_id = %p,
missing = %what,
"feeder plugin degraded: required config not supplied; \
config form still served so it can be fixed from the dashboard"
);
out.insert(
id.to_string(),
make_degraded(format!("required config not supplied: {what}")),
);
}
Err(other) => {
out.insert(id.to_string(), make_degraded(other.to_string()));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::DiscoveryRecord;
use async_trait::async_trait;
use futures::stream::BoxStream;
use std::collections::BTreeMap;
/// A feeder plugin that overrides `handle_query_stream` to emit a Base, then
/// an EnrichPatch, then Done — so the NDJSON route is exercised on a real
/// incremental stream (not just the collect-default).
struct StubStreaming;
#[async_trait]
impl FeederPlugin for StubStreaming {
fn upstream_id(&self) -> &'static str {
"stub"
}
fn configure(&mut self, _cache_dir: &FsPath) -> Result<(), ConfigError> {
Ok(())
}
async fn handle_query(
&self,
_q: &GatewayQuery,
_n: usize,
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
Ok(vec![])
}
async fn handle_query_stream(
&self,
_q: &GatewayQuery,
_n: usize,
) -> Result<BoxStream<'static, GatewaySearchEvent>, GatewayError> {
let rec = DiscoveryRecord {
upstream_id: "stub".into(),
record_id: "1".into(),
fields: BTreeMap::new(),
};
let events = vec![
GatewaySearchEvent::Base(rec),
GatewaySearchEvent::EnrichPatch {
record_id: "1".into(),
set: BTreeMap::from([("title".to_string(), "X".to_string())]),
remove: vec![],
},
GatewaySearchEvent::Done,
];
Ok(Box::pin(futures::stream::iter(events)))
}
async fn compute_outcomes(
&self,
_record_id: &str,
) -> Result<Vec<HashOutcome>, GatewayError> {
Ok(vec![])
}
}
#[tokio::test]
async fn query_stream_emits_ndjson_events_in_order() {
let mut plugins: HashMap<String, Arc<dyn FeederPlugin>> = HashMap::new();
plugins.insert("stub".to_string(), Arc::new(StubStreaming));
let app = router(plugins, "test".to_string(), std::env::temp_dir());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let body = reqwest::Client::new()
.post(format!("http://{addr}/query_stream"))
.json(&serde_json::json!({
"upstream_id": "stub",
"query": {"raw_text":"x","free_text":"x","filters":{},"ranges":[],"negations":[]},
"max_results": 5
}))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let events: Vec<GatewaySearchEvent> = body
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).expect("each NDJSON line is a GatewaySearchEvent"))
.collect();
assert_eq!(events.len(), 3, "body was: {body:?}");
assert!(matches!(events[0], GatewaySearchEvent::Base(_)));
assert!(matches!(events[1], GatewaySearchEvent::EnrichPatch { .. }));
assert!(matches!(events[2], GatewaySearchEvent::Done));
}
}
-124
View File
@@ -1,124 +0,0 @@
//! In-process types used by the gateway runtime, feeders, and plugins.
//!
//! `GatewayError` here is **distinct from** the wire-stable
//! `GatewayWireError` (see [`crate::query`]): this one can carry rich
//! internal context (anyhow chains, plugin specifics); the wire one is narrow
//! on purpose since adding variants is a protocol change. Conversion happens
//! at the protocol boundary.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Content-addressed hash string carried over the wire. Renamed from
/// `Midhash` in the multi-outcome plugin contract migration — the name
/// `Midhash` lied for sha2-256 CIDs (which gateway plugins also emit
/// via `compute_ipfs_cid`). The carrier is generic; the hash family is
/// discriminated by `HashKind` on `HashOutcome`.
///
/// Shape: base32-lower 'b'-prefixed multibase CIDv1. The specific
/// multihash inside (`0x1000` for midhash256, `0x12` for sha2-256) is
/// determined by the producing plugin's hashing path.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Hash(pub String);
impl Hash {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Hash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// Pre-hash identifier for a record discovered through a gateway.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DiscoveryId {
pub upstream_id: String,
pub record_id: String,
}
/// Metadata-only search result. Distinct from meta-core's persistent record
/// (which is a flat `map[string]string` indexed by midhash); a
/// `DiscoveryRecord` only lives inside the gateway search/response pipeline.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveryRecord {
pub upstream_id: String,
pub record_id: String,
pub fields: BTreeMap<String, String>,
}
/// In-process error returned by `FeederPlugin` methods.
#[derive(Debug, thiserror::Error)]
pub enum GatewayError {
#[error("upstream transient error: {0}")]
Transient(String),
#[error("upstream permanent error: {0}")]
Permanent(String),
#[error("rate limited; retry in {retry_after_s}s")]
RateLimited { retry_after_s: u32 },
#[error("record not found upstream")]
NotFound,
#[error("internal: {0}")]
Internal(#[from] anyhow::Error),
}
/// Byte stream returned by a plugin's `handle_fetch`.
pub type ByteStream = futures::stream::BoxStream<'static, Result<bytes::Bytes, GatewayError>>;
/// Plugin liveness snapshot surfaced via `/health`. Must not call upstream
/// — `health()` is invoked on every status poll.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum PluginHealth {
Ok,
Degraded { reason: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_display_and_as_str_match() {
let m = Hash("bafy123".to_string());
assert_eq!(m.as_str(), "bafy123");
assert_eq!(format!("{m}"), "bafy123");
}
#[test]
fn discovery_record_roundtrips_through_serde_json() {
let r = DiscoveryRecord {
upstream_id: "scihub".to_string(),
record_id: "10.1038/s41586-021-03819-2".to_string(),
fields: BTreeMap::from_iter([
("title".to_string(), "AlphaFold".to_string()),
("year".to_string(), "2021".to_string()),
]),
};
let j = serde_json::to_string(&r).expect("serialize");
let back: DiscoveryRecord = serde_json::from_str(&j).expect("deserialize");
assert_eq!(r, back);
}
#[test]
fn plugin_health_serialises_with_tag() {
let ok = serde_json::to_value(PluginHealth::Ok).unwrap();
assert_eq!(ok, serde_json::json!({"state": "ok"}));
let bad = serde_json::to_value(PluginHealth::Degraded {
reason: "upstream 5xx".into(),
})
.unwrap();
assert_eq!(
bad,
serde_json::json!({"state": "degraded", "reason": "upstream 5xx"})
);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ name = "card-feeder"
path = "src/main.rs"
[dependencies]
meta-feeder-sdk = { path = "../../crates/meta-feeder-sdk" }
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v1.7.0" }
tokio = { version = "1.40", features = ["full"] }
axum = { version = "0.7", features = ["json"] }
+2 -3
View File
@@ -2,8 +2,8 @@
# =============================================================================
# card-feeder — metadata-bridge feeder sidecar (the card tier). Multi-stage.
#
# The build context must be the REPO ROOT (the workspace) so the
# `meta-feeder-sdk` path dependency (../../crates/meta-feeder-sdk) resolves.
# The build context must be the REPO ROOT (the workspace). `meta-feeder-sdk` is
# a git dependency pinned by tag (no vendored copy any more), fetched by cargo.
# Same BuildKit cache-mount strategy as the other feeders: the cargo
# registry/git and `target/` persist across builds; the release binary is
# copied OUT of the target cache mount within the same RUN.
@@ -21,7 +21,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY Cargo.toml rust-toolchain.toml ./
COPY Cargo.lock* ./
COPY crates/ crates/
COPY feeder-plugin/ feeder-plugin/
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
+454 -16
View File
@@ -17,11 +17,14 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use meta_feeder_sdk::hash::compute_card_cid;
use meta_feeder_sdk::hash::{compute_card_cid, compute_url_cid};
use meta_feeder_sdk::types::DiscoveryRecord;
use crate::consts::MAX_CARD_SEASONS;
use crate::tmdb_client::{TmdbClient, TmdbKind, TmdbSeasonSummary};
use crate::tmdb_client::{
iso639_1_to_3, norm_tokens, normalize_title_name, title_member_key, TmdbClient, TmdbImage,
TmdbKind, TmdbSeasonSummary,
};
/// The metadata source that published a card. One variant per bridge; the
/// string form is the CID's source namespace, so **changing it re-mints every
@@ -65,16 +68,30 @@ pub(crate) struct Card {
pub(crate) title: String,
/// Canonical + original + AKA titles. Rides along so the *content* feeders
/// can run their resemblance guard against the card instead of re-querying
/// TMDB for it.
/// TMDB for it (phase 2, once the indexer feeder's own anchor resolution is
/// retired).
///
/// Not read here yet — it is consumed by phase 2, which still resolves its
/// own anchor until the indexer feeder's `build_anchor_record` path is
/// retired. Resolving it now means the card is already complete when that
/// switch happens.
#[allow(dead_code)]
/// Read here for one thing: picking `searchTitle` ([`search_aka`]).
pub(crate) guard_titles: Arc<Vec<String>>,
/// Every name of the work as `(lang3, name)`, filed on the record as the
/// `titles/{lang3}/{name}` key-set (METADATA_KEYS.md). Built by
/// `tmdb_client::title_names`: original under its language, `title` under
/// `eng` when it differs, AKAs by market. Same names as `guard_titles`, with
/// the language each is written in.
pub(crate) names: Vec<(&'static str, String)>,
pub(crate) overview: Option<String>,
pub(crate) poster_path: Option<String>,
/// Editorial genre names (`"Animation"`, `"Drama"`, …) — METADATA_KEYS
/// §`genres`. The *work's* categorisation, distinct from `categories/*`,
/// which is the Newznab/Prowlarr distribution taxonomy a release is filed
/// under and which a card (having no release) never carries.
///
/// Free on both resolution paths: a details fetch returns named genre
/// objects, and a list hit's bare `genre_ids` are mapped through
/// `Resolver::genre_names`. May be empty — TMDB genre coverage is good but
/// not universal, and consumers must treat "no genres" as unknown rather
/// than as "none".
pub(crate) genres: Vec<String>,
pub(crate) year: Option<u16>,
// -- structure (TV only) ------------------------------------------------
@@ -87,6 +104,11 @@ pub(crate) struct Card {
/// from a mutated series card.
#[allow(dead_code)]
pub(crate) season_summaries: Arc<Vec<TmdbSeasonSummary>>,
/// Poster candidates (TMDB `images.posters`), filed as the
/// `posters/{lang3}/{cid}` key-set by [`poster_member_keys`]. Only the by-id
/// path has them; discovery/search cards leave this empty and file no set.
pub(crate) posters: Arc<Vec<TmdbImage>>,
}
impl Card {
@@ -123,6 +145,27 @@ impl Card {
n.clamp(1, MAX_CARD_SEASONS)
}
/// The card's synopsis, trimmed and non-empty.
pub(crate) fn overview_text(&self) -> Option<&str> {
self.overview.as_deref().map(str::trim).filter(|s| !s.is_empty())
}
/// The card's poster path, non-empty.
pub(crate) fn poster(&self) -> Option<&str> {
self.poster_path.as_deref().filter(|s| !s.is_empty())
}
/// Would this card actually render? Exactly [`Card::to_record`]'s emit
/// condition, hoisted so a caller can predict the drop *before* projecting.
///
/// [`crate::discovery`] needs this: it walks catalog pages until it has N
/// cards, and counting raw TMDB hits would overcount — a hit with no poster
/// or no overview is silently declined at projection time, leaving a short
/// row. The two must not drift, hence one predicate rather than two.
pub(crate) fn is_displayable(&self) -> bool {
self.overview_text().is_some() && self.poster().is_some()
}
/// Project to the wire record.
///
/// Returns `None` when the card lacks a poster or a description — it would
@@ -139,16 +182,50 @@ impl Card {
tmdb: &TmdbClient,
query_filters: &BTreeMap<String, Vec<String>>,
) -> Option<DiscoveryRecord> {
let overview = self.overview.as_deref().map(str::trim).filter(|s| !s.is_empty())?;
let poster_path = self.poster_path.as_deref().filter(|s| !s.is_empty())?;
// Same two conditions as `Card::is_displayable`, which the discovery page
// walk uses to predict this drop — keep them in one place.
let overview = self.overview_text()?;
let poster_path = self.poster()?;
let mut fields: BTreeMap<String, String> = BTreeMap::new();
// Type axes. `card` on the fileType axis and the work kind on the
// contentKind axis stay orthogonal, so `fileType:card contentKind:series`
// is a well-formed query and routing needs no special case.
fields.insert("fileType".to_string(), "card".to_string());
fields.insert("contentKind".to_string(), self.content_kind().to_string());
let content_kind = self.content_kind();
fields.insert("contentKind".to_string(), content_kind.to_string());
// Third and fourth axes: which app the card is destined for, and what
// shape of work it is. `fileType=card` says "no bytes", `contentKind`
// says which work, `domain` says who wants it — meta-watch's wall is a
// `domain:screen` query — and `workForm` says film-or-serial, the split
// `domain` stopped carrying when film and tv merged (METADATA_KEYS.md
// §1, §14.17).
if let Some(domain) = meta_feeder_sdk::domain::domain_for_content_kind(content_kind) {
fields.insert("domain".to_string(), domain.to_string());
}
if let Some(work_form) =
meta_feeder_sdk::domain::work_form_for_content_kind(content_kind)
{
fields.insert("workForm".to_string(), work_form.to_string());
}
fields.insert("title".to_string(), self.title.clone());
// The keyword a consumer should search indexers with, only when `title`
// itself can't be searched (`3%` → `3 percent`). meta-watch's title page
// has no TMDB access of its own and reads this off the card; without it
// its free-text query sends `3%`, which one newznab answers with nothing
// and another floods with every release containing a `3`.
if let Some(aka) = search_aka(&self.title, self.guard_titles.iter()) {
// Normalised like a `titles/*/*` member name: `searchTitle` is always
// one of the record's names, spelled the same way.
fields.insert("searchTitle".to_string(), normalize_title_name(aka));
}
// Every name the work is known by, as the language-nested key-set. A
// key-set so two peers resolving different AKAs union on key-merge.
for (lang3, name) in &self.names {
if let Some(key) = title_member_key(lang3, name) {
fields.insert(key, "true".to_string());
}
}
// The id bag.
if let Some(id) = self.tmdbid {
@@ -171,6 +248,24 @@ impl Card {
// `overview`).
fields.insert("description/eng".to_string(), overview.to_string());
// Genres as a KEY-SET (`genres/<Name> = "true"`), not the legacy
// comma-joined `genres` value the tmdb/jellyfin plugins still write.
//
// METADATA_KEYS §14.12 is explicit that `csv-set` is the shape being
// migrated *away* from and that "new writers must not introduce more
// csv-set fields — reach for key-set, even if the surrounding family is
// still on the legacy shape". This is a new writer, and the reason bites
// here specifically: a card is the one record type multiple peers derive
// independently at the same CID (§9.4), so two peers resolving different
// genre sets must union by key-merge rather than diverge into two
// comma-joined strings that need string-diffing to reconcile.
for g in &self.genres {
let g = g.trim();
if !g.is_empty() {
fields.insert(format!("genres/{g}"), "true".to_string());
}
}
// The bare-CID key-set member, exactly as every other feeder stamps on
// its search records (`indexer-feeder`'s `torznab/xml.rs` does the same
// with the release's infohash).
@@ -204,9 +299,30 @@ impl Card {
"poster_url".to_string(),
tmdb.poster_cdn_url(poster_path),
);
// The alternative posters, as the language-nested key-set (METADATA_KEYS
// §6 `posters/{lang3}/{cid}`). Every member is a `url` locator, so listing
// them costs no fetch. The primary member wraps the exact `poster_url`
// string above: the gateway matches on it and renames the member to the
// content cid it seeds into `poster` — that is how "`poster` is a member"
// holds for a feeder card.
for key in poster_member_keys(tmdb, poster_path, &self.posters) {
fields.insert(key, "true".to_string());
}
for (key, allowed) in query_filters {
if key == "languages" || fields.contains_key(key) || allowed.is_empty() {
// `genres` joins `languages` as an echo exclusion, for the same
// reason: it is stored as a key-set, so the flat field this loop
// would write is not the shape a reader looks for. Worse, the filter
// value is a *slug* (`action-adventure` — the query DSL can't spell
// "Action & Adventure", space being its token separator), so echoing
// it would persist a slug masquerading as a genre name alongside the
// real `genres/<Name>` members. Both re-validating tiers read the
// key-set, so nothing needs the echo.
if key == "languages"
|| key == "genres"
|| fields.contains_key(key)
|| allowed.is_empty()
{
continue;
}
fields.insert(key.clone(), allowed.join(","));
@@ -220,6 +336,117 @@ impl Card {
}
}
/// Most members a record's `posters/*` key-set may carry, the primary included
/// (METADATA_KEYS.md §6, writer rule 1).
pub(crate) const MAX_POSTER_MEMBERS: usize = 10;
/// The `posters/{lang3}/{cid}` keys for a card: the primary poster first, then
/// the best-voted alternatives, capped at [`MAX_POSTER_MEMBERS`]. Empty when
/// TMDB returned no image list — without one the primary's language is unknown,
/// and a set is better absent than mislabelled.
///
/// Ranked by `vote_average`, then `vote_count`, then `file_path`, the last so
/// two peers holding the same payload emit the same keys. The language is that
/// of the text printed on the poster: `zxx` for a textless one (TMDB `null`),
/// `und` for a code outside [`iso639_1_to_3`] or a primary TMDB did not list.
///
/// CROSS-BINARY CONTRACT: same ranking, cap, CDN size and language rule as the
/// tmdb plugin's `posterMembers` (`metamesh-plugin-tmdb/src/posters.ts`).
pub(crate) fn poster_member_keys(
tmdb: &TmdbClient,
primary_path: &str,
candidates: &[TmdbImage],
) -> Vec<String> {
if candidates.is_empty() {
return Vec::new();
}
let lang3 = |img: &TmdbImage| -> &'static str {
match img.iso_639_1.as_deref().map(str::trim) {
None | Some("") => "zxx",
Some(code) => iso639_1_to_3(code).unwrap_or("und"),
}
};
let mut keys: Vec<String> = Vec::new();
let primary_lang = candidates
.iter()
.find(|img| img.file_path == primary_path)
.map_or("und", lang3);
if let Some(cid) = compute_url_cid(&tmdb.poster_cdn_url(primary_path)) {
keys.push(format!("posters/{primary_lang}/{cid}"));
}
let mut ranked: Vec<&TmdbImage> = candidates
.iter()
.filter(|img| !img.file_path.trim().is_empty() && img.file_path != primary_path)
.collect();
ranked.sort_by(|a, b| {
b.vote_average
.total_cmp(&a.vote_average)
.then(b.vote_count.cmp(&a.vote_count))
.then(a.file_path.cmp(&b.file_path))
});
for img in ranked {
if keys.len() >= MAX_POSTER_MEMBERS {
break;
}
if let Some(cid) = compute_url_cid(&tmdb.poster_cdn_url(&img.file_path)) {
let key = format!("posters/{}/{cid}", lang3(img));
if !keys.contains(&key) {
keys.push(key);
}
}
}
keys
}
/// Punctuation keyword searches fold to a space. Only used to *exclude* these
/// from [`is_search_hostile_symbol`] — a folded character searches fine.
///
/// CROSS-BINARY CONTRACT: identical to the indexer feeder's
/// `torznab/mod.rs::KEYWORD_PUNCTUATION`, and to meta-watch's
/// `clean_title` + `sanitize_free_text` sets combined.
const KEYWORD_PUNCTUATION: [char; 11] = ['.', '_', ':', ';', ',', '-', '/', '(', ')', '=', '!'];
/// An ASCII symbol an indexer's search box can neither carry nor safely lose
/// (`%` in `3%`): punctuation outside [`KEYWORD_PUNCTUATION`], apostrophe
/// excepted. See the indexer feeder's `is_search_hostile_symbol` for the
/// measurements.
///
/// CROSS-BINARY CONTRACT: same rule as the indexer feeder's
/// `torznab/mod.rs::is_search_hostile_symbol` and meta-watch's
/// `catalog::query::has_search_hostile_symbol`.
fn is_search_hostile_symbol(c: char) -> bool {
c.is_ascii_punctuation() && c != '\'' && !KEYWORD_PUNCTUATION.contains(&c)
}
/// The AKA to search with in place of `title` — the card's `searchTitle`.
/// `None` unless `title` carries a [search-hostile symbol](is_search_hostile_symbol)
/// **and** an AKA is symbol-free and strictly more specific (all of the title's
/// word tokens, plus at least one): `3 percent` for `3%`, never the bare `3`.
/// First qualifying AKA wins; `TmdbAltTitles::all` orders English markets first.
///
/// CROSS-BINARY CONTRACT: mirrors the indexer feeder's
/// `torznab/mod.rs::search_aka`, which picks the anchored jobs' `q=` the same
/// way. Change both.
pub(crate) fn search_aka<'a>(
title: &str,
akas: impl IntoIterator<Item = &'a String>,
) -> Option<&'a str> {
if !title.chars().any(is_search_hostile_symbol) {
return None;
}
let want = norm_tokens(title);
if want.is_empty() {
return None;
}
akas.into_iter()
.filter(|aka| !aka.chars().any(is_search_hostile_symbol))
.find(|aka| {
let have = norm_tokens(aka);
have.len() > want.len() && want.iter().all(|w| have.contains(w))
})
.map(String::as_str)
}
/// Split a card `record_id` (`"tmdb:tv:95479"`) back into `(source, source_id)`
/// — the two halves of the CID preimage. `None` when the id carries no source
/// prefix.
@@ -241,11 +468,18 @@ mod tests {
imdb_id: Some("tt22248376".to_string()),
title: "Frieren: Beyond Journey's End".to_string(),
guard_titles: Arc::new(vec!["Sousou no Frieren".to_string()]),
names: vec![
("jpn", "葬送のフリーレン".to_string()),
("eng", "Frieren: Beyond Journey's End".to_string()),
("jpn", "Sousou no Frieren".to_string()),
],
overview: Some("The story follows the elf mage Frieren.".to_string()),
poster_path: Some("/dqZENchTd7lp5zit1Q7Bkjzcxpi.jpg".to_string()),
genres: vec!["Animation".to_string(), "Sci-Fi & Fantasy".to_string()],
year: Some(2023),
seasons: 1,
season_summaries: Arc::new(Vec::new()),
posters: Arc::new(Vec::new()),
}
}
@@ -314,6 +548,102 @@ mod tests {
assert!(no_overview.to_record(&tmdb, &BTreeMap::new()).is_none());
}
/// `3%` (TMDB tv 68467): the card spells the symbol out for searchers, and
/// keeps the real title for display.
#[test]
fn symbol_title_card_carries_a_search_title() {
let tmdb = TmdbClient::new("token".to_string());
let mut card = tv_card();
card.title = "3%".to_string();
card.guard_titles = Arc::new(
["3%", "3 %", "3", "Three Percent", "3 percent", "3 Pourcent"]
.iter()
.map(|s| s.to_string())
.collect(),
);
let rec = card.to_record(&tmdb, &BTreeMap::new()).expect("record");
assert_eq!(rec.fields["title"], "3%");
assert_eq!(rec.fields.get("searchTitle").map(String::as_str), Some("3 percent"));
// A searchable title gets no searchTitle — Frieren's apostrophe and
// colon are not hostile.
let rec = tv_card().to_record(&tmdb, &BTreeMap::new()).expect("record");
assert!(!rec.fields.contains_key("searchTitle"));
}
#[test]
fn record_files_every_name_as_a_language_nested_key_set() {
let tmdb = TmdbClient::new("token".to_string());
let rec = tv_card().to_record(&tmdb, &BTreeMap::new()).expect("record");
for key in [
"titles/jpn/葬送のフリーレン",
"titles/eng/Frieren: Beyond Journey's End",
"titles/jpn/Sousou no Frieren",
] {
assert_eq!(rec.fields.get(key).map(String::as_str), Some("true"), "{key}");
}
// Leaves only: no scalar at the language level.
assert!(!rec.fields.keys().any(|k| k.starts_with("titles/") && k.matches('/').count() == 1));
}
/// The METADATA_KEYS.md example, end to end from TMDB's data.
#[test]
fn title_names_match_the_three_percent_example() {
use crate::tmdb_client::{title_names, TmdbAltTitle};
let aka = |m: &str, t: &str| TmdbAltTitle { title: t.to_string(), iso_3166_1: m.to_string() };
let names = title_names(
"3%",
Some("3%"),
Some("pt"),
&[aka("US", "3 Percent"), aka("FR", "3 Pourcent"), aka("BR", "3%"), aka("CA", "3 Por Cento ")],
);
assert_eq!(
names,
vec![
("por", "3%".to_string()),
("eng", "3 Percent".to_string()),
("fra", "3 Pourcent".to_string()),
("und", "3 Por Cento".to_string()),
],
"title == original is filed once; BR `3%` repeats por/3%; CA is multilingual → und"
);
// A translated title is filed under eng beside the original.
let names = title_names("Naruto", Some("ナルト"), Some("ja"), &[]);
assert_eq!(names, vec![("jpn", "ナルト".to_string()), ("eng", "Naruto".to_string())]);
// Unknown original language → und; TMDB's placeholder is not a name.
assert_eq!(title_names("(untitled)", Some("X"), Some("xx"), &[]), vec![("und", "X".to_string())]);
}
#[test]
fn title_member_key_normalises_like_two_peers_must() {
assert_eq!(title_member_key("jpn", "Fate/Zero").as_deref(), Some("titles/jpn/Fate\u{2215}Zero"));
assert_eq!(title_member_key("eng", " 3 Percent ").as_deref(), Some("titles/eng/3 Percent"));
assert_eq!(title_member_key("eng", "Pokémon").as_deref(), Some("titles/eng/Pokémon"));
assert_eq!(title_member_key("eng", " "), None);
}
#[test]
fn search_aka_never_picks_something_weaker() {
let akas = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(search_aka("M*A*S*H", akas(&["MASH"]).iter()), None);
assert_eq!(search_aka("3%", akas(&["3", "3 %"]).iter()), None);
assert_eq!(search_aka("Law & Order", akas(&["Law and Order"]).iter()), Some("Law and Order"));
assert_eq!(search_aka("Naruto", akas(&["Naruto Shippuden"]).iter()), None);
}
#[test]
fn tmdb_alt_titles_put_english_markets_first() {
let alt: crate::tmdb_client::TmdbAltTitles = serde_json::from_str(
r#"{"results":[{"iso_3166_1":"FR","title":"3 Pourcent"},{"iso_3166_1":"US","title":"3 percent"},{"title":"no market"}]}"#,
)
.expect("decode");
assert_eq!(alt.all(), vec!["3 percent", "3 Pourcent", "no market"]);
assert!(alt.has_markets());
let legacy: crate::tmdb_client::TmdbAltTitles =
serde_json::from_str(r#"{"results":[{"title":"3 Pourcent"},{"title":"3 percent"}]}"#).expect("decode");
assert!(!legacy.has_markets(), "a pre-market cache entry must refetch");
assert!(crate::tmdb_client::TmdbAltTitles::default().has_markets(), "no AKAs is complete");
}
#[test]
fn movie_card_uses_the_movie_content_kind() {
let mut card = tv_card();
@@ -326,17 +656,125 @@ mod tests {
}
/// Query filters are echoed so the record survives `record_matches` on the
/// way out, but `languages` is deliberately skipped (it fails open).
/// way out — except the two key-set fields, which a reader resolves from
/// their `<prefix>/<member>` members rather than from a flat value.
#[test]
fn query_filters_are_echoed_except_languages() {
fn query_filters_are_echoed_except_the_key_set_fields() {
let tmdb = TmdbClient::new("token".to_string());
let mut filters = BTreeMap::new();
filters.insert("fileType".to_string(), vec!["card".to_string()]);
filters.insert("genres".to_string(), vec!["fantasy".to_string()]);
filters.insert("popular".to_string(), vec!["true".to_string()]);
filters.insert("genres".to_string(), vec!["action-adventure".to_string()]);
filters.insert("languages".to_string(), vec!["jpn".to_string()]);
let rec = tv_card().to_record(&tmdb, &filters).expect("record");
assert_eq!(rec.fields["genres"], "fantasy");
assert_eq!(rec.fields["popular"], "true", "ordinary filters echo");
assert_eq!(rec.fields["fileType"], "card", "own value wins the echo");
assert!(!rec.fields.contains_key("languages"));
// The genre filter carries a *slug* the DSL can spell; echoing it would
// persist "action-adventure" as if it were a genre name, beside the real
// `genres/Action & Adventure` members.
assert!(
!rec.fields.contains_key("genres"),
"the genre slug must not be echoed as a flat field"
);
assert_eq!(rec.fields["genres/Animation"], "true");
assert_eq!(rec.fields["genres/Sci-Fi & Fantasy"], "true");
}
fn img(path: &str, lang: Option<&str>, avg: f64, count: u32) -> TmdbImage {
TmdbImage {
file_path: path.to_string(),
iso_639_1: lang.map(str::to_string),
vote_average: avg,
vote_count: count,
}
}
/// METADATA_KEYS §6 `posters/{lang3}/{cid}`: the primary is a member, filed
/// under its own language, as the locator of the exact `poster_url` the
/// gateway seeds — that equality is what lets the gateway rename it.
#[test]
fn record_files_the_primary_poster_as_a_locator_member() {
let tmdb = TmdbClient::new("token".to_string());
let mut card = tv_card();
let primary = card.poster_path.clone().expect("fixture poster");
card.posters = Arc::new(vec![
img("/alt-fr.jpg", Some("fr"), 5.5, 10),
img(&primary, Some("ja"), 5.0, 3),
img("/textless.jpg", None, 6.0, 1),
]);
let rec = card.to_record(&tmdb, &BTreeMap::new()).expect("record");
let primary_cid = compute_url_cid(&rec.fields["poster_url"]).expect("locator");
assert_eq!(
rec.fields.get(&format!("posters/jpn/{primary_cid}")).map(String::as_str),
Some("true")
);
let fr = compute_url_cid(&tmdb.poster_cdn_url("/alt-fr.jpg")).expect("locator");
let textless = compute_url_cid(&tmdb.poster_cdn_url("/textless.jpg")).expect("locator");
assert!(rec.fields.contains_key(&format!("posters/fra/{fr}")));
assert!(rec.fields.contains_key(&format!("posters/zxx/{textless}")));
let members = rec.fields.keys().filter(|k| k.starts_with("posters/")).count();
assert_eq!(members, 3, "the primary is listed once, not twice");
// Leaves only, and never a 639-2/B code.
assert!(!rec.fields.keys().any(|k| k.starts_with("posters/") && k.matches('/').count() != 2));
assert!(!rec.fields.keys().any(|k| k.starts_with("posters/fre/")));
}
#[test]
fn poster_members_rank_by_votes_and_cap_at_ten() {
let tmdb = TmdbClient::new("token".to_string());
let candidates: Vec<TmdbImage> = (0..15)
.map(|i| img(&format!("/p{i:02}.jpg"), Some("en"), f64::from(i), 1))
.collect();
let keys = poster_member_keys(&tmdb, "/primary.jpg", &candidates);
assert_eq!(keys.len(), MAX_POSTER_MEMBERS);
// Primary first; TMDB didn't list it among its images, so `und`.
let primary = compute_url_cid(&tmdb.poster_cdn_url("/primary.jpg")).expect("locator");
assert_eq!(keys[0], format!("posters/und/{primary}"));
// Then the best vote_average: p14, p13, … p06 — p05 is cut.
let best = compute_url_cid(&tmdb.poster_cdn_url("/p14.jpg")).expect("locator");
assert_eq!(keys[1], format!("posters/eng/{best}"));
let cut = compute_url_cid(&tmdb.poster_cdn_url("/p05.jpg")).expect("locator");
assert!(!keys.contains(&format!("posters/eng/{cut}")));
// vote_count breaks a vote_average tie.
let tie = poster_member_keys(
&tmdb,
"/primary.jpg",
&[img("/few.jpg", None, 5.0, 1), img("/many.jpg", None, 5.0, 9)],
);
let many = compute_url_cid(&tmdb.poster_cdn_url("/many.jpg")).expect("locator");
assert_eq!(tie[1], format!("posters/zxx/{many}"));
// An unmapped language is `und`, never a 2-letter key.
let odd = poster_member_keys(&tmdb, "/primary.jpg", &[img("/x.jpg", Some("xx"), 1.0, 1)]);
assert!(odd[1].starts_with("posters/und/"), "{odd:?}");
}
/// No image list (a discovery hit, a pre-append cache entry): no set at all,
/// rather than a primary filed under a guessed `und`.
#[test]
fn card_without_an_image_list_files_no_poster_set() {
let tmdb = TmdbClient::new("token".to_string());
let rec = tv_card().to_record(&tmdb, &BTreeMap::new()).expect("record");
assert!(rec.fields.contains_key("poster_url"), "poster itself is unaffected");
assert!(!rec.fields.keys().any(|k| k.starts_with("posters/")));
}
#[test]
fn tmdb_images_append_decodes() {
let d: crate::tmdb_client::TmdbTvDetails = serde_json::from_str(
r#"{"name":"X","images":{"posters":[{"file_path":"/a.jpg","iso_639_1":null,"vote_average":5.3,"vote_count":4},{"file_path":"/b.jpg","iso_639_1":"fr","vote_average":0}]}}"#,
)
.expect("decode");
let posters = d.images.expect("images").posters;
assert_eq!(posters.len(), 2);
assert_eq!(posters[0].iso_639_1, None);
assert_eq!(posters[1].iso_639_1.as_deref(), Some("fr"));
assert_eq!(posters[1].vote_count, 0);
// A pre-append cache entry still decodes, with no image list.
let old: crate::tmdb_client::TmdbTvDetails =
serde_json::from_str(r#"{"name":"X"}"#).expect("decode");
assert!(old.images.is_none());
}
}
+73
View File
@@ -30,6 +30,70 @@ pub(crate) const TMDB_RATE_LIMIT_DEFAULT_RETRY_SECS: u64 = 5;
/// Maximum time to wait for a TMDB budget permit before giving up (seconds).
pub(crate) const TMDB_WAIT_DEADLINE_SECS: u64 = 3000;
/// Budget-permit deadline for a **catalog page** ([`crate::discovery`]),
/// deliberately far shorter than [`TMDB_WAIT_DEADLINE_SECS`].
///
/// That 3000 s ceiling is sized for a query someone is *waiting on* — a user
/// typed a title and wants that specific answer, so out-waiting a 429 pause
/// beats returning nothing. A browse row is the opposite: nobody asked for
/// *this* row in particular, it refreshes on a timer, and its client keeps the
/// previously-filled row on an empty pass (meta-watch's sticky per-row merge).
/// Inheriting 3000 s would let a single 429 pin a row's search stream open for
/// most of an hour to eventually produce what a 5 s bail produces now.
pub(crate) const TMDB_DISCOVERY_WAIT_DEADLINE_SECS: u64 = 5;
/// Hard ceiling on catalog pages walked for one discovery row, whatever the
/// requested card count. Bounds a misconfigured `card_discovery_n` (or a client
/// asking for 500) to a predictable per-row TMDB cost.
pub(crate) const DISCOVERY_MAX_PAGES: u32 = 5;
/// TMDB keyword ids excluded from an `anime:true` discovery row, as
/// `without_keywords`: `hentai`, `softcore`, `erotica`, `pornography`,
/// `adult animation`.
///
/// **Why this is needed at all.** `include_adult=false` does *not* cover it:
/// TMDB's `adult` boolean is reserved for actual pornography, and every one of
/// the titles this excludes comes back `adult: false`. Measured on the live
/// endpoint, the flag removed 84 of 4465 anime series and none of the offenders.
///
/// **Why they surface in the first place.** `sort_by=popularity.desc` sorts on
/// TMDB's *traffic* metric (page views, API hits, list adds), not on audience
/// size or rating. Adult titles attract disproportionate scraping and search
/// traffic, so they score far above their audience: measured, one carried
/// popularity 313 on **26 votes** and another 242 on **7 votes**, against
/// Bleach's 92 on **2197**. Narrowing the pool to the anime keyword removes the
/// mainstream ballast that keeps them off the front of `/tv/popular`, which is
/// why only the anime rows show it.
///
/// **Why these five and not `erotic`/`ecchi`.** Those two are *content
/// descriptors* a mainstream show can legitimately carry — Mushoku Tensei
/// (tmdb 94664, 1626 votes) carries both — so excluding them costs real titles.
/// The five here tag adult *productions*, and no false positive was observed.
/// Keyword ids are stable; names resolved via `search/keyword`.
pub(crate) const DISCOVERY_EXCLUDED_KEYWORDS: &str = "198385,155477,325693,445,161919";
/// Minimum TMDB vote count for a discovery row entry.
///
/// The residue filter behind [`DISCOVERY_EXCLUDED_KEYWORDS`]: a handful of
/// erotic-tagged titles carry none of the five excluded keywords, and they are
/// uniformly low-signal (7–37 votes). A vote requires a real logged-in account,
/// which is exactly the signal the traffic-based popularity score lacks, so a
/// floor separates them from genuinely obscure-but-real titles far better than
/// popularity can.
///
/// 50 is deliberately low. It keeps Ranma½ (169) and Doraemon (237) — it is a
/// noise gate, not a popularity contest. Raising it much further starts costing
/// legitimate niche titles (Akane-banashi 25, Gravitation 17), which is why the
/// keyword exclusion carries the main load and this only sweeps up behind it.
///
/// Applies to `/discover` rows, which is now every mode except non-anime
/// **trending** — the one signal with no `/discover` equivalent, and therefore
/// the one row that cannot carry this gate. The popular/top-rated modes were
/// moved off `/{kind}/popular` and `/{kind}/top_rated` precisely so they could:
/// three hentai titles were measured at positions 4, 5 and 17 of the
/// *non-anime* `tv/popular` row.
pub(crate) const DISCOVERY_MIN_VOTES: u32 = 50;
/// Default number of cards returned for a free-text query (top-N by TMDB
/// popularity).
///
@@ -47,6 +111,15 @@ pub(crate) const DEFAULT_CARD_TOP_N: usize = 10;
/// never needs a cache wipe — reads slice the cached list to the configured N.
pub(crate) const CACHED_PRINCIPAL_DEPTH: usize = 30;
/// Default number of cards a **keyword-less catalog** query returns (a home
/// row: `popular:true contentKind:series fileType:card`).
///
/// 20 is TMDB's own catalog page size, so the default row costs exactly one
/// TMDB call. Raising it past 20 is legal — [`crate::discovery`] walks further
/// pages — but each page is another call against the shared budget, paid on
/// every warm pass of every row.
pub(crate) const DEFAULT_CARD_DISCOVERY_N: usize = 20;
/// Defensive ceiling on a card's reported season count (guards a corrupt
/// `number_of_seasons`).
pub(crate) const MAX_CARD_SEASONS: u32 = 50;
+722
View File
@@ -0,0 +1,722 @@
//! Keyword-less catalog discovery: TMDB's popular / trending / top-rated lists
//! → [`Card`]s.
//!
//! The card tier's *browse* surface, as opposed to its search surface. A client
//! with a home page (meta-watch's wall) cannot ask "what should I show?" through
//! a text query — there is no text. This branch answers a query whose intent is
//! carried entirely by structured filters, by calling TMDB's catalog endpoints.
//!
//! ## Query dialect
//!
//! Deliberately identical to the indexer feeder's older `discovery.rs`, which
//! answers the same dialect with metadata-only *seeds*. Same words, so a client
//! switches tiers by swapping `fileType:video` for `fileType:card` and nothing
//! else:
//!
//! - **mode** (one of) — `popular:true` and `top_rated:true` are served by
//! `/discover/{movie,tv}` sorted by popularity / rating (rather than the
//! `/{movie,tv}/popular` list endpoints, which cannot carry the adult gate —
//! see `DISCOVERY_EXCLUDED_KEYWORDS`); `trending:true` → the real
//! `/trending/{movie,tv}/week`, the one mode with no `/discover` equivalent
//! and therefore the one row that stays ungated.
//! - **kind** (required) — `contentKind:movie` → films,
//! `contentKind:series` (or `episode`/`tvshow`/`tv`) → shows. Also what routes
//! the query here at all, alongside `fileType:card`.
//! - **anime** (optional) — `anime:true` narrows `/discover` to TMDB's *anime*
//! keyword, sorted to match the mode. Anime trending has no keyword-filtered
//! endpoint either, so it approximates with popularity — and stays gated.
//!
//! ## One call, complete cards
//!
//! A TMDB catalog list already carries `title`, `overview`, `poster_path` and
//! `year` per entry — everything [`Card::to_record`] needs to emit a renderable
//! card. So a row costs **one** TMDB call, not one plus twenty by-id lookups.
//!
//! The price is a thinner id bag: `tvdbid`/`imdbid` come from `external_ids`,
//! which a list response has no room for, so a discovery card carries only its
//! `tmdbid`. That is the id consumers actually key on (it is what the detail
//! page's phase-2 `tmdbid:` query is built from), and the full bag is resolved
//! anyway the moment someone clicks the card — `card_by_id` is a different code
//! path and still fetches it. Paying twenty extra calls per row up front to
//! pre-fill ids nobody reads until a click would defeat the branch's purpose.
//!
//! ## Why nothing is cached here
//!
//! A catalog list is **mutable by nature** — "popular this week" is the one
//! thing that must not be frozen. The SDK's redb tables are permanent-hit caches
//! sized for immutable data (`external_ids` is explicitly "cached forever"), so
//! persisting a popularity list there would pin the wall to whatever it looked
//! like on first boot. The layer above already de-duplicates the repeat cost:
//! the gateway persists each emitted card to meta-core and its search-coverage
//! gate then skips a repeated `(upstream, query)` for an hour, serving meta-core
//! instead. Every call here is budget-gated, so a burst cannot run away.
use std::collections::HashMap;
use std::sync::Arc;
use meta_feeder_sdk::plugin::GatewayQuery;
use tracing::debug;
use crate::card::{Card, CardSource};
use crate::consts::{DISCOVERY_EXCLUDED_KEYWORDS, DISCOVERY_MAX_PAGES, DISCOVERY_MIN_VOTES};
use crate::resolve::{dedup_titles, Resolver};
use crate::tmdb_client::{title_names, TmdbHit, TmdbKind};
/// TMDB keyword id for "anime". `/discover` is the only catalog endpoint that
/// takes a keyword filter, which is why `anime:true` switches endpoints rather
/// than adding a parameter.
const TMDB_ANIME_KEYWORD: &str = "210024";
/// Results TMDB returns per catalog page — used to translate a desired card
/// count into a page count.
const TMDB_PAGE_SIZE: usize = 20;
/// The catalog-list flavour a discovery query asks for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum DiscoveryMode {
Popular,
Trending,
TopRated,
}
impl DiscoveryMode {
/// The query-filter key that selects this mode.
///
/// It is also echoed onto every emitted record — not here, but by
/// [`Card::to_record`]'s trailing filter echo, which stamps back every
/// filter the query carried that the card did not already set. That echo is
/// load-bearing: both the gateway dispatcher and meta-search's consumer-side
/// `record_matches` re-apply the query's filters to each record, and a
/// *missing* field fails the match, so an un-echoed `popular` would see
/// every card dropped at one of the two tiers.
fn marker(self) -> &'static str {
match self {
DiscoveryMode::Popular => "popular",
DiscoveryMode::Trending => "trending",
DiscoveryMode::TopRated => "top_rated",
}
}
/// `sort_by` value for the `/discover` (anime) path. `/discover` has no
/// "trending", so trending approximates to popularity there — the non-anime
/// path still uses the real `/trending` endpoint.
fn discover_sort(self) -> &'static str {
match self {
DiscoveryMode::TopRated => "vote_average.desc",
DiscoveryMode::Popular | DiscoveryMode::Trending => "popularity.desc",
}
}
}
/// True when this is a keyword-less catalog query this branch should answer.
///
/// Three conditions, and each excludes a query that belongs to a *different*
/// branch of `handle_query`: no free text (that is the principal search), no
/// `tmdbid:` filter (that is the direct by-id lookup a deep link uses), and a
/// truthy mode marker. Note `contentKind` alone deliberately does **not** select
/// this branch — a text query carries it too.
pub(crate) fn is_discovery_query(query: &GatewayQuery) -> bool {
query.free_text.trim().is_empty()
&& !query.filters.contains_key("tmdbid")
&& discovery_mode(query).is_some()
}
/// First truthy mode marker on the query, in precedence order.
fn discovery_mode(query: &GatewayQuery) -> Option<DiscoveryMode> {
for mode in [
DiscoveryMode::Trending,
DiscoveryMode::Popular,
DiscoveryMode::TopRated,
] {
if filter_is_true(query, mode.marker()) {
return Some(mode);
}
}
None
}
/// True iff `query.filters[key]` carries a truthy value.
fn filter_is_true(query: &GatewayQuery, key: &str) -> bool {
query
.filters
.get(key)
.is_some_and(|v| v.iter().any(|s| s.eq_ignore_ascii_case("true")))
}
/// Map the query's `contentKind` filter to a TMDB media kind.
///
/// `series` is the canonical spelling for a card (it describes a whole work),
/// but `episode`/`tvshow`/`tv` are accepted so the dialect stays a superset of
/// the indexer feeder's — a client that has not yet switched its row queries
/// still resolves the right endpoint here, even though its records will then be
/// dropped downstream for the mismatch.
fn discovery_kind(query: &GatewayQuery) -> Option<TmdbKind> {
let values = query.filters.get("contentKind")?;
for v in values {
match v.trim().to_ascii_lowercase().as_str() {
"movie" => return Some(TmdbKind::Movie),
"series" | "episode" | "tvshow" | "tv" => return Some(TmdbKind::Tv),
_ => {}
}
}
None
}
/// The genre slugs a `genres:` filter requested, folded for comparison.
///
/// Multi-valued = OR, matching the filter semantics on both re-validating tiers.
fn requested_genres(query: &GatewayQuery) -> Vec<String> {
query
.filters
.get("genres")
.map(|vs| {
vs.iter()
.map(|v| genre_fold(v))
.filter(|v| !v.is_empty())
.collect()
})
.unwrap_or_default()
}
/// Fold a genre label for comparison: lowercase, alphanumerics only.
///
/// Must stay identical to `genres_filter_matches`'s fold in the SDK and in
/// meta-search — those two re-apply the filter to every record this emits, so a
/// divergence here means the row's own results get dropped downstream.
fn genre_fold(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect()
}
/// Resolve the requested genre slugs to TMDB genre ids via the id → name table.
/// Unknown slugs are dropped; if none resolve the caller omits `with_genres`
/// rather than sending an empty filter that TMDB would reject.
fn with_genres_ids(wanted: &[String], names: &HashMap<u32, String>) -> Vec<u32> {
let mut ids: Vec<u32> = names
.iter()
.filter(|(_, name)| wanted.contains(&genre_fold(name)))
.map(|(id, _)| *id)
.collect();
ids.sort_unstable();
ids
}
/// Build the `path_and_query` for `TmdbClient::discovery_list` from the mode,
/// kind, anime flag, requested genre ids, and 1-based `page`.
fn build_path_and_query(
mode: DiscoveryMode,
kind: TmdbKind,
anime: bool,
genre_ids: &[u32],
page: u32,
) -> String {
let seg = match kind {
TmdbKind::Movie => "movie",
TmdbKind::Tv => "tv",
};
// Trending is the one mode with no `/discover` equivalent: `/trending` is a
// distinct, TMDB-computed "this week" signal, not a sort order. So it keeps
// the list endpoint — and, being a list endpoint, **cannot carry the adult
// gate below**. The anime variant still routes through `/discover` (there is
// no keyword-filtered trending at all), approximating it with popularity.
// …and a genre-scoped row can't use it either: `/trending` takes no
// `with_genres`, and returning an unfiltered trending page for an "Action"
// row would fill it with whatever is trending. `/discover` sorted by
// popularity is the closest filterable approximation.
if matches!(mode, DiscoveryMode::Trending) && !anime && genre_ids.is_empty() {
return format!("trending/{seg}/week?page={page}");
}
// Everything else goes through `/discover`, including the non-anime rows
// that used to use `/{seg}/popular` and `/{seg}/top_rated`.
//
// Those fixed list endpoints accept neither `without_keywords` nor
// `vote_count.gte`, and measurement showed they genuinely need them: three
// hentai titles reached positions 4, 5 and 17 of the *non-anime* `tv/popular`
// row for the same traffic-metric reason described on
// [`DISCOVERY_EXCLUDED_KEYWORDS`]. `/discover` with `sort_by=popularity.desc`
// is the filterable equivalent, so one code path now carries the gate for
// every mode that can hold it.
//
// Top-rated floors higher than the shared gate: sorting by rating puts a
// single 10.0 vote on top, which a 50-vote gate doesn't stop.
let votes = match mode {
DiscoveryMode::TopRated => 200,
_ => DISCOVERY_MIN_VOTES,
};
let keyword = if anime {
format!("with_keywords={TMDB_ANIME_KEYWORD}&")
} else {
String::new()
};
// `with_genres` is comma-joined = AND on TMDB's side, pipe-joined = OR. A
// multi-valued filter means OR everywhere else in the DSL, so join with `|`
// to keep the wire semantics and the upstream semantics saying the same
// thing. URL-encoded, since a bare `|` is not valid in a query string.
let genres = if genre_ids.is_empty() {
String::new()
} else {
let joined = genre_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join("%7C");
format!("with_genres={joined}&")
};
format!(
"discover/{seg}?{keyword}{genres}sort_by={}&include_adult=false\
&without_keywords={DISCOVERY_EXCLUDED_KEYWORDS}&vote_count.gte={votes}&page={page}",
mode.discover_sort(),
)
}
/// Project one catalog hit into a [`Card`], with no further network calls.
///
/// `source_id` uses the same `"tv:{id}"` / `"movie:{id}"` shape
/// `Resolver::card_by_id` produces, so a discovery card's `record_id` — and
/// therefore its `0x1007` locator CID — is byte-identical to the one the by-id
/// path derives for the same work. That is what lets a card the wall surfaced
/// and a card the search box surfaced converge on one record instead of two.
fn card_from_hit(hit: &TmdbHit, kind: TmdbKind, genres: &HashMap<u32, String>) -> Card {
let seg = match kind {
TmdbKind::Movie => "movie",
TmdbKind::Tv => "tv",
};
let mut titles = vec![hit.title.clone()];
if let Some(o) = &hit.original_title {
titles.push(o.clone());
}
titles.extend(hit.alt_titles.iter().cloned());
Card {
source: CardSource::Tmdb,
kind,
source_id: format!("{seg}:{}", hit.tmdbid),
tmdbid: Some(hit.tmdbid),
// Not available on a list response — see the module doc. Resolved on
// the click-time `card_by_id` path instead.
tvdb_id: None,
imdb_id: None,
title: hit.title.clone(),
guard_titles: Arc::new(dedup_titles(titles)),
// A list hit carries no AKAs, so this is title + original only; the
// click-time `card_by_id` card adds the AKAs under the same CID.
names: title_names(
&hit.title,
hit.original_title.as_deref(),
hit.original_language.as_deref(),
&hit.akas,
),
overview: hit.overview.clone().filter(|s| !s.trim().is_empty()),
poster_path: hit.poster_path.clone().filter(|s| !s.trim().is_empty()),
// A list hit carries bare genre ids; the table names them.
genres: hit.genre_ids.iter().filter_map(|id| genres.get(id).cloned()).collect(),
year: hit.year,
// A list response carries no season structure. `seasonCount` is simply
// omitted from the record (`Card::to_record` skips it at `seasons == 0`).
seasons: 0,
season_summaries: Arc::new(Vec::new()),
// A list response carries no `images` append, so a discovery card files
// no `posters/*` set; the click-time `card_by_id` card adds it under the
// same CID.
posters: Arc::new(Vec::new()),
}
}
/// Resolve a keyword-less catalog query to up to `max_results` cards, walking
/// TMDB pages until the cap is reached or a page comes back empty.
///
/// Best-effort throughout: no mode or no `contentKind` yields `[]`, and a
/// rate-limit or miss breaks the walk and returns what was already gathered.
/// The branch never errors — an empty row is the failure mode, matching
/// `Resolver::cards_for_text`.
pub(crate) async fn cards_for_discovery(
resolver: &Resolver,
query: &GatewayQuery,
max_results: usize,
) -> Vec<Card> {
let (Some(mode), Some(kind)) = (discovery_mode(query), discovery_kind(query)) else {
return Vec::new();
};
let anime = filter_is_true(query, "anime");
// Fetched once per process, not per row (see `Resolver::genre_names`).
let gmap = resolver.genre_names(kind).await;
// A `genres:` filter narrows the row upstream via `with_genres`, so the row
// is a full page OF that genre rather than a page filtered down to it.
let wanted = requested_genres(query);
let genre_ids = with_genres_ids(&wanted, &gmap);
if !wanted.is_empty() && genre_ids.is_empty() {
// Asked for a genre this kind doesn't have (e.g. `genres:kids` on the
// movie side, where TMDB files it as "Family"). Returning an unfiltered
// page would silently fill the row with the wrong thing.
debug!(
target: "meta-share::gateway", upstream = "tmdb",
?wanted, "discovery: no TMDB genre id matched; row left empty"
);
return Vec::new();
}
// One page of headroom over the arithmetic minimum: `out` counts *displayable*
// cards, and a catalog page reliably contains a few entries with no overview
// or no poster (newly-added or non-English works), which are declined at
// projection. Without the spare page a 20-card row comes back at 17.
let pages = (max_results.div_ceil(TMDB_PAGE_SIZE).max(1) as u32 + 1).min(DISCOVERY_MAX_PAGES);
let mut out: Vec<Card> = Vec::new();
for page in 1..=pages {
if out.len() >= max_results {
break;
}
let path_and_query = build_path_and_query(mode, kind, anime, &genre_ids, page);
let Some(hits) = resolver.discovery_page(kind, &path_and_query).await else {
break; // budget exhausted, 429, or miss — keep what we have
};
if hits.is_empty() {
break; // walked past the last catalog page
}
out.extend(
hits.iter()
.map(|h| card_from_hit(h, kind, &gmap))
.filter(Card::is_displayable),
);
}
out.truncate(max_results);
debug!(
target: "meta-share::gateway",
upstream = "tmdb",
mode = mode.marker(),
anime,
count = out.len(),
"resolved discovery cards"
);
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn query(filters: &[(&str, &str)]) -> GatewayQuery {
let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (k, v) in filters {
map.entry(k.to_string()).or_default().push(v.to_string());
}
GatewayQuery {
raw_text: String::new(),
free_text: String::new(),
filters: map,
ranges: Vec::new(),
negations: Vec::new(),
}
}
#[test]
fn selects_only_keyword_less_mode_queries() {
assert!(is_discovery_query(&query(&[
("popular", "true"),
("contentKind", "series"),
("fileType", "card"),
])));
// A mode marker but with free text → the principal search owns it.
let mut with_text = query(&[("popular", "true"), ("contentKind", "series")]);
with_text.free_text = "frieren".to_string();
assert!(!is_discovery_query(&with_text));
// An explicit id → the by-id lookup owns it.
assert!(!is_discovery_query(&query(&[
("popular", "true"),
("tmdbid", "95479"),
])));
// contentKind alone must NOT select this branch.
assert!(!is_discovery_query(&query(&[
("contentKind", "series"),
("fileType", "card"),
])));
// A falsey marker is not a marker.
assert!(!is_discovery_query(&query(&[("popular", "false")])));
}
/// `contentKind:series` is the canonical card spelling; the indexer feeder's
/// older `episode` spelling still resolves the same endpoint.
#[test]
fn content_kind_maps_series_and_the_legacy_spellings() {
assert_eq!(
discovery_kind(&query(&[("contentKind", "series")])),
Some(TmdbKind::Tv)
);
assert_eq!(
discovery_kind(&query(&[("contentKind", "episode")])),
Some(TmdbKind::Tv)
);
assert_eq!(
discovery_kind(&query(&[("contentKind", "movie")])),
Some(TmdbKind::Movie)
);
assert_eq!(discovery_kind(&query(&[("contentKind", "audiobook")])), None);
assert_eq!(discovery_kind(&query(&[])), None);
}
#[test]
fn builds_the_catalog_endpoints() {
// Popular / top-rated route through /discover (so the gate applies);
// only the sort and the vote floor differ.
let pop = build_path_and_query(DiscoveryMode::Popular, TmdbKind::Movie, false, &[], 1);
assert!(pop.starts_with("discover/movie?"), "{pop}");
assert!(pop.contains("sort_by=popularity.desc"), "{pop}");
assert!(pop.contains("page=1"), "{pop}");
let paged = build_path_and_query(DiscoveryMode::Popular, TmdbKind::Tv, false, &[], 2);
assert!(paged.starts_with("discover/tv?"), "{paged}");
assert!(paged.contains("page=2"), "{paged}");
let top = build_path_and_query(DiscoveryMode::TopRated, TmdbKind::Movie, false, &[], 1);
assert!(top.contains("sort_by=vote_average.desc"), "{top}");
assert!(top.contains("vote_count.gte=200"), "{top}");
// Trending keeps the real endpoint — see the dedicated test.
assert_eq!(
build_path_and_query(DiscoveryMode::Trending, TmdbKind::Tv, false, &[], 1),
"trending/tv/week?page=1"
);
}
#[test]
fn anime_switches_to_the_keyword_filtered_discover_endpoint() {
let p = build_path_and_query(DiscoveryMode::Popular, TmdbKind::Tv, true, &[], 1);
assert!(p.starts_with("discover/tv?"), "{p}");
assert!(p.contains("with_keywords=210024"), "{p}");
assert!(p.contains("sort_by=popularity.desc"), "{p}");
let top = build_path_and_query(DiscoveryMode::TopRated, TmdbKind::Movie, true, &[], 1);
assert!(top.contains("sort_by=vote_average.desc"), "{top}");
// Sorting by rating puts a 1-vote 10.0 on top, so top-rated floors higher.
assert!(top.contains("vote_count.gte=200"), "{top}");
}
/// The gate is not anime-specific. Three hentai titles were measured at
/// positions 4, 5 and 17 of the **non-anime** `tv/popular` row, which is why
/// the plain popular/top-rated modes were moved off the fixed list endpoints
/// (which cannot carry `without_keywords`) and onto `/discover`.
#[test]
fn non_anime_popular_and_top_rated_also_gate() {
for mode in [DiscoveryMode::Popular, DiscoveryMode::TopRated] {
let p = build_path_and_query(mode, TmdbKind::Tv, false, &[], 1);
assert!(p.starts_with("discover/tv?"), "{p}");
assert!(!p.contains("with_keywords="), "no anime narrowing here: {p}");
assert!(p.contains("without_keywords="), "{p}");
assert!(p.contains("vote_count.gte="), "{p}");
}
}
/// Trending is the documented exception: `/trending` is a TMDB-computed
/// signal with no `/discover` sort equivalent, so the non-anime trending row
/// keeps the list endpoint and cannot carry the gate.
#[test]
fn non_anime_trending_keeps_the_real_trending_endpoint() {
let p = build_path_and_query(DiscoveryMode::Trending, TmdbKind::Tv, false, &[], 1);
assert_eq!(p, "trending/tv/week?page=1");
// …but the anime variant has no keyword-filtered trending at all, so it
// still routes through /discover and stays gated.
let a = build_path_and_query(DiscoveryMode::Trending, TmdbKind::Tv, true, &[], 1);
assert!(a.starts_with("discover/tv?"), "{a}");
assert!(a.contains("without_keywords="), "{a}");
}
/// A genre-scoped row narrows **upstream** via `with_genres`, so the row is a
/// full page of that genre rather than a page filtered down to a handful.
#[test]
fn a_genre_row_narrows_upstream() {
let p = build_path_and_query(DiscoveryMode::Popular, TmdbKind::Tv, false, &[10759], 1);
assert!(p.contains("with_genres=10759"), "{p}");
// Multi-valued = OR, so pipe-joined (comma would mean AND to TMDB).
let multi = build_path_and_query(DiscoveryMode::Popular, TmdbKind::Tv, false, &[16, 35], 1);
assert!(multi.contains("with_genres=16%7C35"), "{multi}");
// No genre filter ⇒ the parameter is absent, not empty.
let none = build_path_and_query(DiscoveryMode::Popular, TmdbKind::Tv, false, &[], 1);
assert!(!none.contains("with_genres"), "{none}");
}
/// A genre row can't use `/trending` (it takes no `with_genres`), so it falls
/// back to the filterable `/discover` rather than returning an unfiltered
/// trending page under a genre heading.
#[test]
fn a_genre_scoped_trending_row_falls_back_to_discover() {
let p = build_path_and_query(DiscoveryMode::Trending, TmdbKind::Tv, false, &[10759], 1);
assert!(p.starts_with("discover/tv?"), "{p}");
assert!(p.contains("with_genres=10759"), "{p}");
}
/// Slugs resolve to TMDB ids through the id → name table, folded on both
/// sides so the DSL never has to spell a space or an ampersand.
#[test]
fn genre_slugs_resolve_to_tmdb_ids() {
let names = HashMap::from([
(16u32, "Animation".to_string()),
(10759, "Action & Adventure".to_string()),
(10765, "Sci-Fi & Fantasy".to_string()),
]);
let want = |s: &str| with_genres_ids(&[genre_fold(s)], &names);
assert_eq!(want("action-adventure"), vec![10759]);
assert_eq!(want("Action & Adventure"), vec![10759]);
assert_eq!(want("scififantasy"), vec![10765]);
assert_eq!(want("animation"), vec![16]);
assert!(want("horror").is_empty(), "unknown slug resolves to nothing");
}
/// The fold must stay identical to the one both re-validating tiers use, or
/// a row's own results get dropped downstream.
#[test]
fn genre_fold_matches_the_consumer_side_rule() {
assert_eq!(genre_fold("Action & Adventure"), "actionadventure");
assert_eq!(genre_fold("action-adventure"), "actionadventure");
assert_eq!(genre_fold("Sci-Fi & Fantasy"), "scififantasy");
assert_eq!(genre_fold("Science Fiction"), "sciencefiction");
assert_eq!(genre_fold(" "), "");
}
/// The adult-content gate on anime rows, and *why* it is two filters.
///
/// `include_adult=false` is not enough on its own — measured live, every
/// offending title returns `adult: false` (TMDB reserves that flag for
/// pornography), so the keyword exclusion carries the real load and the vote
/// floor sweeps up the erotic-tagged residue behind it. Dropping either one
/// puts hentai back on the home page.
#[test]
fn anime_rows_gate_adult_content_three_ways() {
for mode in [
DiscoveryMode::Popular,
DiscoveryMode::Trending,
DiscoveryMode::TopRated,
] {
for kind in [TmdbKind::Tv, TmdbKind::Movie] {
let p = build_path_and_query(mode, kind, true, &[], 1);
assert!(p.contains("include_adult=false"), "{p}");
assert!(
p.contains(&format!("without_keywords={DISCOVERY_EXCLUDED_KEYWORDS}")),
"{p}"
);
assert!(p.contains("vote_count.gte="), "{p}");
}
}
}
fn genre_table() -> HashMap<u32, String> {
HashMap::from([(16, "Animation".to_string()), (18, "Drama".to_string())])
}
fn hit() -> TmdbHit {
TmdbHit {
akas: Vec::new(),
tmdbid: 95479,
title: "Frieren: Beyond Journey's End".to_string(),
original_title: Some("葬送のフリーレン".to_string()),
original_language: Some("ja".to_string()),
overview: Some("A mage reflects on her long life.".to_string()),
year: Some(2023),
poster_path: Some("/poster.jpg".to_string()),
genre_ids: vec![16],
alt_titles: Vec::new(),
posters: Vec::new(),
}
}
/// A list hit must carry everything a renderable card needs, so the branch
/// costs one TMDB call per page and no by-id follow-ups.
#[test]
fn a_list_hit_becomes_a_complete_card() {
let c = card_from_hit(&hit(), TmdbKind::Tv, &genre_table());
assert_eq!(c.tmdbid, Some(95479));
assert!(c.overview.is_some(), "overview is what to_record requires");
assert!(c.poster_path.is_some(), "poster is what to_record requires");
assert_eq!(c.year, Some(2023));
assert_eq!(c.content_kind(), "series");
// Genres come from the id table — the category axis a card used to lack.
assert_eq!(c.genres, vec!["Animation".to_string()]);
// The id bag beyond tmdbid needs external_ids — deliberately absent.
assert_eq!(c.tvdb_id, None);
assert_eq!(c.imdb_id, None);
}
/// The locator CID is derived from `record_id`, so a discovery card and a
/// by-id card for the same work must address identically or the two tiers
/// would publish two records for one work.
#[test]
fn record_id_matches_the_by_id_path() {
assert_eq!(card_from_hit(&hit(), TmdbKind::Tv, &genre_table()).record_id(), "tmdb:tv:95479");
assert_eq!(
card_from_hit(&hit(), TmdbKind::Movie, &genre_table()).record_id(),
"tmdb:movie:95479"
);
}
/// The whole point of the branch: `contentKind` on the emitted record is the
/// *card's* kind, never an echo of the query filter. A row asking
/// `contentKind:episode` must still produce `series` here — which is exactly
/// why a client's row query has to ask for `series` (the mismatch is then
/// caught by `record_matches` downstream rather than silently mislabelling).
/// **The invariant this whole branch rests on.** The mode/anime markers are
/// stamped onto the record by `Card::to_record`'s trailing filter echo, not
/// by anything here — and both the gateway dispatcher and meta-search
/// re-apply the query's filters to every record, where a *missing* field
/// fails the match. So if that echo is ever "tidied up", every discovery
/// card is silently dropped in transit and the wall goes blank with no
/// error. This test fails that day.
#[test]
fn a_discovery_card_survives_the_consumer_re_filter() {
let q = query(&[
("popular", "true"),
("contentKind", "series"),
("fileType", "card"),
("anime", "true"),
]);
let card = card_from_hit(&hit(), TmdbKind::Tv, &genre_table());
let tmdb = crate::tmdb_client::TmdbClient::new("token".to_string());
let rec = card.to_record(&tmdb, &q.filters).expect("displayable card emits");
assert_eq!(rec.fields.get("popular").map(String::as_str), Some("true"));
assert_eq!(rec.fields.get("anime").map(String::as_str), Some("true"));
assert_eq!(rec.fields.get("fileType").map(String::as_str), Some("card"));
assert_eq!(
rec.fields.get("contentKind").map(String::as_str),
Some("series"),
"the card's own kind must win over the query filter"
);
assert!(rec.fields.contains_key("description/eng"));
assert!(rec.fields.contains_key("poster_url"));
// Genres ride as a key-set, never the legacy comma-joined value
// (METADATA_KEYS §14.12 — new writers must not add csv-set fields).
assert_eq!(rec.fields.get("genres/Animation").map(String::as_str), Some("true"));
assert!(!rec.fields.contains_key("genres"), "must not write the legacy csv-set");
// The thin id bag: resolved lazily on the click-time by-id path.
assert!(!rec.fields.contains_key("tvdbid"));
assert!(!rec.fields.contains_key("imdbid"));
assert!(!rec.fields.contains_key("seasonCount"));
assert!(
meta_feeder_sdk::query_eval::record_matches(&rec.fields, &q),
"a discovery card must survive the same filter re-application the \
dispatcher and meta-search perform"
);
}
/// A hit TMDB has not written up yet is declined at projection — which is
/// why the page walk counts displayable cards, not raw hits.
#[test]
fn an_incomplete_hit_is_not_displayable() {
let mut no_overview = hit();
no_overview.overview = None;
assert!(!card_from_hit(&no_overview, TmdbKind::Tv, &genre_table()).is_displayable());
let mut no_poster = hit();
no_poster.poster_path = None;
assert!(!card_from_hit(&no_poster, TmdbKind::Tv, &genre_table()).is_displayable());
assert!(card_from_hit(&hit(), TmdbKind::Tv, &genre_table()).is_displayable());
}
#[test]
fn content_kind_comes_from_the_card_not_the_filter() {
let tv = card_from_hit(&hit(), TmdbKind::Tv, &genre_table());
assert_eq!(tv.content_kind(), "series");
let movie = card_from_hit(&hit(), TmdbKind::Movie, &genre_table());
assert_eq!(movie.content_kind(), "movie");
}
}
+1
View File
@@ -12,6 +12,7 @@
pub mod card;
pub mod consts;
pub mod discovery;
pub mod resolve;
pub mod tmdb;
pub mod tmdb_budget;
+142 -7
View File
@@ -9,6 +9,7 @@
//! The redb table names are unchanged from the indexer feeder's cache, so a
//! warm cache carries straight over.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
@@ -16,10 +17,13 @@ use meta_feeder_sdk::cache::MidhashCache;
use tracing::debug;
use crate::card::{Card, CardSource};
use crate::consts::{CACHED_PRINCIPAL_DEPTH, TMDB_WAIT_DEADLINE_SECS};
use crate::consts::{
CACHED_PRINCIPAL_DEPTH, TMDB_DISCOVERY_WAIT_DEADLINE_SECS, TMDB_WAIT_DEADLINE_SECS,
};
use crate::tmdb_budget::{Lease, TmdbBudget};
use crate::tmdb_client::{
principal_top_n, TmdbCall, TmdbClient, TmdbExternalIds, TmdbHit, TmdbKind, TmdbTvDetails,
principal_top_n, title_names, TmdbAltTitles, TmdbCall, TmdbClient, TmdbExternalIds, TmdbHit,
TmdbKind, TmdbTvDetails,
};
/// TMDB resolution with a persistent cache in front and a token budget behind.
@@ -28,6 +32,14 @@ pub(crate) struct Resolver {
pub(crate) client: Arc<TmdbClient>,
pub(crate) cache: MidhashCache,
pub(crate) budget: Arc<TmdbBudget>,
/// `genre id → name` per kind, held for the process lifetime.
///
/// In memory rather than in the redb cache on purpose: the table is 16 TV /
/// 19 movie entries and effectively static, so a process-local map costs one
/// call per kind per boot and needs no invalidation story — whereas the redb
/// tables are permanent-hit and shared byte-for-byte with the indexer
/// feeder's, which this is not part of.
genre_names: Arc<std::sync::RwLock<HashMap<&'static str, Arc<HashMap<u32, String>>>>>,
}
impl Resolver {
@@ -36,20 +48,68 @@ impl Resolver {
client,
cache,
budget,
genre_names: Arc::new(std::sync::RwLock::new(HashMap::new())),
}
}
/// The `genre id → name` table for `kind`, fetched once and then reused.
///
/// Needed only by the **list** paths (discovery/search), where TMDB gives
/// bare `genre_ids`; a details fetch returns named genre objects directly.
/// Best-effort: a failed or empty fetch yields an empty map (cards simply
/// carry no genres) and is **not** cached, so the next row retries.
pub(crate) async fn genre_names(&self, kind: TmdbKind) -> Arc<HashMap<u32, String>> {
let key = match kind {
TmdbKind::Tv => "tv",
TmdbKind::Movie => "movie",
};
if let Some(m) = self.genre_names.read().ok().and_then(|g| g.get(key).cloned()) {
return m;
}
let fetched: HashMap<u32, String> = self
.budgeted_with_deadline(
TMDB_DISCOVERY_WAIT_DEADLINE_SECS,
self.client.genre_list(kind),
|gs| {
gs.into_iter()
.filter(|g| !g.name.trim().is_empty())
.map(|g| (g.id, g.name))
.collect()
},
)
.await
.unwrap_or_default();
let arc = Arc::new(fetched);
if !arc.is_empty() {
if let Ok(mut g) = self.genre_names.write() {
g.insert(key, arc.clone());
}
}
arc
}
/// Acquire a budget token, run `call`, map a hit through `on_hit`. A 429
/// pauses the shared bucket globally rather than being retried here.
async fn budgeted<T, R>(
&self,
call: impl std::future::Future<Output = TmdbCall<T>>,
on_hit: impl FnOnce(T) -> R,
) -> Option<R> {
self.budgeted_with_deadline(TMDB_WAIT_DEADLINE_SECS, call, on_hit)
.await
}
/// [`Self::budgeted`] with a caller-chosen permit deadline, for callers whose
/// cost of waiting differs from a user-facing lookup's — see
/// [`TMDB_DISCOVERY_WAIT_DEADLINE_SECS`].
async fn budgeted_with_deadline<T, R>(
&self,
deadline_secs: u64,
call: impl std::future::Future<Output = TmdbCall<T>>,
on_hit: impl FnOnce(T) -> R,
) -> Option<R> {
if matches!(
self.budget
.acquire(Duration::from_secs(TMDB_WAIT_DEADLINE_SECS))
.await,
self.budget.acquire(Duration::from_secs(deadline_secs)).await,
Lease::DeadlineExceeded
) {
return None;
@@ -90,7 +150,11 @@ impl Resolver {
if let Ok(Some(json)) = self.cache.get_tmdb_moviedetails(&key) {
if json != "null" {
if let Ok(hit) = serde_json::from_str::<TmdbHit>(&json) {
return Some(hit);
// Self-heal: AKAs cached without their markets can't be
// filed under `titles/{lang3}/{name}` — refetch once.
if hit.alt_titles.is_empty() || !hit.akas.is_empty() {
return Some(hit);
}
}
}
}
@@ -124,6 +188,29 @@ impl Resolver {
.await
}
/// One page of a TMDB **catalog list** (popular / trending / top-rated /
/// `discover`) — the keyword-less browse primitive behind
/// [`crate::discovery`]. `None` on a budget timeout, a 429, or a miss, which
/// the caller treats as "stop walking pages and keep what you have".
///
/// Deliberately **uncached**, unlike every other method here: a catalog list
/// is the one TMDB response that is *supposed* to change under you, and the
/// redb tables in front of the others are permanent-hit caches with no TTL.
/// See the module doc of [`crate::discovery`] for why the repeat cost is
/// already absorbed a layer up.
pub(crate) async fn discovery_page(
&self,
kind: TmdbKind,
path_and_query: &str,
) -> Option<Vec<TmdbHit>> {
self.budgeted_with_deadline(
TMDB_DISCOVERY_WAIT_DEADLINE_SECS,
self.client.discovery_list(kind, path_and_query),
|hits| hits,
)
.await
}
/// Cached principal `search/multi`: free text → up to `n` confident
/// `(tmdbid, kind)` candidates, most popular first.
///
@@ -183,7 +270,18 @@ impl Resolver {
.as_deref()
.and_then(|d| d.get(0..4))
.and_then(|y| y.parse::<u16>().ok());
let names = title_names(
&details.name,
details.original_name.as_deref(),
details.original_language.as_deref(),
&details
.alternative_titles
.as_ref()
.map(TmdbAltTitles::entries)
.unwrap_or_default(),
);
Some(Card {
names,
source: CardSource::Tmdb,
kind,
source_id: format!("tv:{tmdbid}"),
@@ -194,19 +292,43 @@ impl Resolver {
guard_titles: Arc::new(dedup_titles(details.guard_titles())),
overview: details.overview.clone().filter(|s| !s.is_empty()),
poster_path: details.poster_path.clone().filter(|s| !s.is_empty()),
genres: genre_names_of(&details.genres),
year,
seasons: Card::clamp_seasons(details.number_of_seasons),
season_summaries: Arc::new(details.seasons.clone()),
posters: Arc::new(
details
.images
.as_ref()
.map(|i| i.posters.clone())
.unwrap_or_default(),
),
})
}
TmdbKind::Movie => {
let hit = self.movie_hit(tmdbid).await?;
// The movie path round-trips through `TmdbHit` (that is what the
// details cache stores), which keeps genres as bare ids — so
// unlike the TV branch above it needs the id → name table.
let gmap = self.genre_names(TmdbKind::Movie).await;
let genres: Vec<String> = hit
.genre_ids
.iter()
.filter_map(|id| gmap.get(id).cloned())
.collect();
let mut titles = vec![hit.title.clone()];
if let Some(o) = &hit.original_title {
titles.push(o.clone());
}
titles.extend(hit.alt_titles.iter().cloned());
let names = title_names(
&hit.title,
hit.original_title.as_deref(),
hit.original_language.as_deref(),
&hit.akas,
);
Some(Card {
names,
source: CardSource::Tmdb,
kind,
source_id: format!("movie:{tmdbid}"),
@@ -217,9 +339,11 @@ impl Resolver {
guard_titles: Arc::new(dedup_titles(titles)),
overview: hit.overview.filter(|s| !s.is_empty()),
poster_path: hit.poster_path.filter(|s| !s.is_empty()),
genres,
year: hit.year,
seasons: 0,
season_summaries: Arc::new(Vec::new()),
posters: Arc::new(hit.posters),
})
}
}
@@ -244,8 +368,19 @@ impl Resolver {
}
}
/// Genre display names off a details payload's `{id, name}` array, trimmed and
/// de-duplicated, order preserved.
pub(crate) fn genre_names_of(genres: &[crate::tmdb_client::TmdbGenre]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
genres
.iter()
.map(|g| g.name.trim().to_string())
.filter(|n| !n.is_empty() && seen.insert(n.to_lowercase()))
.collect()
}
/// Case-insensitive de-dup preserving first-seen order.
fn dedup_titles(titles: Vec<String>) -> Vec<String> {
pub(crate) fn dedup_titles(titles: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
titles
.into_iter()
+49 -4
View File
@@ -22,7 +22,7 @@ use meta_feeder_sdk::types::{DiscoveryRecord, GatewayError, Hash, PluginHealth};
use tracing::warn;
use crate::card::{split_record_id, Card, CardSource};
use crate::consts::DEFAULT_CARD_TOP_N;
use crate::consts::{DEFAULT_CARD_DISCOVERY_N, DEFAULT_CARD_TOP_N};
use crate::resolve::Resolver;
use crate::tmdb_budget::{TmdbBudget, DEFAULT_TMDB_BURST, DEFAULT_TMDB_REFILL_PER_SEC};
use crate::tmdb_client::TmdbClient;
@@ -39,6 +39,8 @@ pub struct TmdbConfig {
pub tmdb_burst: Option<f64>,
#[serde(default)]
pub card_top_n: Option<usize>,
#[serde(default)]
pub card_discovery_n: Option<usize>,
}
impl TmdbConfig {
@@ -51,6 +53,9 @@ impl TmdbConfig {
.and_then(|v| v.parse().ok()),
tmdb_burst: std::env::var("TMDB_BURST").ok().and_then(|v| v.parse().ok()),
card_top_n: std::env::var("CARD_TOP_N").ok().and_then(|v| v.parse().ok()),
card_discovery_n: std::env::var("CARD_DISCOVERY_N")
.ok()
.and_then(|v| v.parse().ok()),
}
}
}
@@ -62,6 +67,8 @@ pub struct TmdbCardPlugin {
/// `depends_on` is satisfied; queries just return nothing.
resolver: Option<Resolver>,
top_n: usize,
/// Cards per keyword-less catalog row (see [`DEFAULT_CARD_DISCOVERY_N`]).
discovery_n: usize,
/// Test hook: `(api_base, image_base)` overriding TMDB's real endpoints, so
/// the contract test can point the client at a wiremock server.
api_bases: Option<(String, String)>,
@@ -79,6 +86,7 @@ impl TmdbCardPlugin {
config: TmdbConfig::from_env(),
resolver: None,
top_n: DEFAULT_CARD_TOP_N,
discovery_n: DEFAULT_CARD_DISCOVERY_N,
api_bases: None,
}
}
@@ -111,6 +119,9 @@ impl TmdbCardPlugin {
if file_cfg.card_top_n.is_some() {
self.config.card_top_n = file_cfg.card_top_n;
}
if file_cfg.card_discovery_n.is_some() {
self.config.card_discovery_n = file_cfg.card_discovery_n;
}
}
}
}
@@ -142,6 +153,11 @@ impl FeederPlugin for TmdbCardPlugin {
self.load_config(cache_dir);
let cache: MidhashCache = meta_feeder_sdk::common::open_midhash_cache(cache_dir, "tmdb")?;
self.top_n = self.config.card_top_n.filter(|n| *n > 0).unwrap_or(DEFAULT_CARD_TOP_N);
self.discovery_n = self
.config
.card_discovery_n
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_CARD_DISCOVERY_N);
let token = self.config.tmdb_token.trim().to_string();
if token.is_empty() {
@@ -189,15 +205,33 @@ impl FeederPlugin for TmdbCardPlugin {
self.served_file_types(),
self.served_content_kinds(),
) {
// One misconfiguration lands here often enough to deserve a name.
// A browse row written against the *indexer* feeder's dialect says
// `contentKind:episode`, which is not in `served_content_kinds` — so
// the row is rejected here, before TMDB is touched, and the operator
// sees an empty wall with no error anywhere. A card describes a WORK,
// and a work is a `series`, never an `episode`.
if crate::discovery::is_discovery_query(query) {
warn!(
target: "meta-share::gateway",
upstream = "tmdb",
content_kind = ?query.filters.get("contentKind"),
"discovery row rejected by the served-kinds gate: a card is a \
WORK — use contentKind:series for TV rows (not episode/tv/tvshow)"
);
}
return Ok(Vec::new());
}
let Some(resolver) = self.resolver() else {
return Ok(Vec::new()); // soft-skipped (no token)
};
// An explicit `tmdbid:` filter resolves exactly one card — the direct
// "give me this work" lookup a deep link uses. Otherwise the free text
// goes through the principal search.
// Three shapes, in precedence order:
// 1. an explicit `tmdbid:` filter — the direct "give me this work"
// lookup a deep link (or the detail page) uses;
// 2. a keyword-less catalog query (`popular:true contentKind:series`)
// — a client's home row, answered from TMDB's catalog endpoints;
// 3. free text — the principal search.
let cards: Vec<Card> = if let Some(id) = query
.filters
.get("tmdbid")
@@ -206,6 +240,9 @@ impl FeederPlugin for TmdbCardPlugin {
{
let kind = kind_hint(query, resolver, id).await;
resolver.card_by_id(id, kind).await.into_iter().collect()
} else if crate::discovery::is_discovery_query(query) {
let n = self.discovery_n.min(max_results.max(1));
crate::discovery::cards_for_discovery(resolver, query, n).await
} else {
let free_text = query.free_text.trim();
if free_text.is_empty() {
@@ -284,6 +321,13 @@ impl FeederPlugin for TmdbCardPlugin {
Blank keeps the built-in default (10). Unlike the old anchor \
top-N this costs no indexer requests — only TMDB.",
),
F::number("card_discovery_n", "Cards per discovery row").with_help(
"How many cards a keyword-less browse row (popular / trending / \
top-rated) returns. Blank keeps the built-in default (20 — \
exactly one TMDB catalog page, so a row costs ONE request). \
Separate from 'Cards per search': a row is a browse surface, \
not an answer to a question.",
),
],
}
}
@@ -294,6 +338,7 @@ impl FeederPlugin for TmdbCardPlugin {
"tmdb_rate_per_sec": self.config.tmdb_rate_per_sec,
"tmdb_burst": self.config.tmdb_burst,
"card_top_n": self.config.card_top_n,
"card_discovery_n": self.config.card_discovery_n,
})
}
}
+259 -14
View File
@@ -190,7 +190,7 @@ impl TmdbClient {
/// any failure returns `None` so validation degrades to "accept".
pub(crate) async fn tv_details(&self, tmdbid: u64) -> TmdbCall<TmdbTvDetails> {
let url = format!(
"{}/tv/{}?append_to_response=alternative_titles",
"{}/tv/{}?append_to_response=alternative_titles,images",
self.api_base.trim_end_matches('/'),
tmdbid,
);
@@ -253,7 +253,7 @@ impl TmdbClient {
/// [`tv_details`].
pub(crate) async fn movie_details(&self, tmdbid: u64) -> TmdbCall<TmdbMovieDetails> {
let url = format!(
"{}/movie/{}?append_to_response=alternative_titles",
"{}/movie/{}?append_to_response=alternative_titles,images",
self.api_base.trim_end_matches('/'),
tmdbid
);
@@ -309,6 +309,27 @@ impl TmdbClient {
}
}
/// `GET /3/genre/{tv,movie}/list` — the id → name table for the `genre_ids`
/// a discovery/search list hit carries.
///
/// TMDB returns genres as bare ids on list responses and as `{id, name}`
/// objects only on a details fetch, so the *browse* path has no way to name
/// a genre without this table. It is tiny (16 TV / 19 movie entries) and
/// effectively static, which is why the caller holds it for the process
/// lifetime instead of paying it per row.
pub(crate) async fn genre_list(&self, kind: TmdbKind) -> TmdbCall<Vec<TmdbGenre>> {
let seg = match kind {
TmdbKind::Movie => "movie",
TmdbKind::Tv => "tv",
};
let url = format!("{}/genre/{seg}/list", self.api_base.trim_end_matches('/'));
match self.get_json::<TmdbGenreList>(&url, "genre_list").await {
TmdbCall::Hit(r) => TmdbCall::Hit(r.genres),
TmdbCall::Miss => TmdbCall::Miss,
TmdbCall::RateLimited(d) => TmdbCall::RateLimited(d),
}
}
/// Shared `GET <url>` → JSON helper with the standard bearer auth, search
/// timeout, 429→`RateLimited`, non-2xx/transient→`Miss` contract. Factored
/// out of [`tv_details`]/[`external_ids`]/[`movie_details`] (identical
@@ -393,6 +414,21 @@ pub(crate) enum TmdbKind {
Tv,
}
/// One `{id, name}` genre entry, as returned by `genre/{kind}/list` and by the
/// details endpoints' `genres` array.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub(crate) struct TmdbGenre {
pub(crate) id: u32,
#[serde(default)]
pub(crate) name: String,
}
#[derive(serde::Deserialize)]
struct TmdbGenreList {
#[serde(default)]
genres: Vec<TmdbGenre>,
}
#[derive(serde::Deserialize)]
pub(crate) struct TmdbSearchResponse {
#[serde(default)]
@@ -421,8 +457,8 @@ pub(crate) struct TmdbSearchItem {
#[serde(default)]
pub(crate) genre_ids: Vec<u32>,
/// ISO 639-1 (2-letter) language the title was originally produced in.
/// Mapped to `lang3` and used to file `original_title` under
/// `titles/{lang3}` (METADATA_KEYS.md §3).
/// Mapped to `lang3` and used to file `original_title` as a
/// `titles/{lang3}/{name}` member (METADATA_KEYS.md §3).
#[serde(default)]
pub(crate) original_language: Option<String>,
}
@@ -457,6 +493,8 @@ impl TmdbSearchItem {
genre_ids: self.genre_ids,
// search-list items carry no AKAs (a details/append fetch does)
alt_titles: Vec::new(),
akas: Vec::new(),
posters: Vec::new(),
}
}
}
@@ -464,8 +502,8 @@ impl TmdbSearchItem {
/// Map an ISO 639-1 (2-letter) code to its ISO 639-3 (`lang3`) equivalent,
/// matching the store's convention (`eng`, `jpn`, `fra`; the 639-2/T variant
/// where B/T differ, e.g. `deu` not `ger`). Covers the languages TMDB
/// commonly returns; unknown codes return `None` so the caller skips the
/// `titles/{lang3}` write rather than persisting a non-`lang3` key.
/// commonly returns; unknown codes return `None` so the caller files the name under `und`
/// rather than persisting a non-`lang3` key.
pub(crate) fn iso639_1_to_3(code: &str) -> Option<&'static str> {
Some(match code.to_ascii_lowercase().as_str() {
"en" => "eng",
@@ -505,6 +543,113 @@ pub(crate) fn iso639_1_to_3(code: &str) -> Option<&'static str> {
})
}
/// The language TMDB's `name`/`title` is written in. Every request goes out
/// without a `language` param, so TMDB answers in its default, en-US.
pub(crate) const METADATA_LANG3: &str = "eng";
/// A title as a `titles/{lang3}/{name}` member name (METADATA_KEYS.md): trimmed,
/// internal whitespace collapsed to one space, case/diacritics/`%` verbatim.
/// The `/` → `∕` mapping is the key's job ([`title_member_key`]), not the name's.
pub(crate) fn normalize_title_name(name: &str) -> String {
name.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The `titles/{lang3}/{name}` key-set member for `name`, or `None` when the
/// name normalises to empty. `/` is the key-set separator, so a `/` inside a
/// name (`Fate/Zero`) is written as U+2215 `∕` (METADATA_KEYS.md).
///
/// CROSS-BINARY CONTRACT: the indexer feeder's `tmdb.rs::title_member_key`
/// writes the same shape; two peers must produce the same key for one name.
pub(crate) fn title_member_key(lang3: &str, name: &str) -> Option<String> {
let name = normalize_title_name(name);
if name.is_empty() {
return None;
}
Some(format!("titles/{lang3}/{}", name.replace('/', "\u{2215}")))
}
/// The language a TMDB AKA market (`iso_3166_1`) clearly implies, or `und` for
/// a multilingual or unknown market (METADATA_KEYS.md `titles/{lang3}/{name}`:
/// map what is unambiguous, never guess). `CA`, `CH`, `BE`, `IN`, `HK`, `SG`,
/// empty, … are `und` on purpose.
pub(crate) fn market_lang3(market: &str) -> &'static str {
match market.trim().to_ascii_uppercase().as_str() {
"US" | "GB" | "AU" | "NZ" | "IE" => "eng",
"FR" => "fra",
"DE" | "AT" => "deu",
"ES" | "MX" | "AR" | "CO" | "CL" | "PE" | "VE" | "UY" => "spa",
"BR" | "PT" => "por",
"IT" => "ita",
"JP" => "jpn",
"KR" => "kor",
"CN" | "TW" => "zho",
"RU" => "rus",
"UA" => "ukr",
"PL" => "pol",
"NL" => "nld",
"SE" => "swe",
"NO" => "nor",
"DK" => "dan",
"FI" => "fin",
"TR" => "tur",
"GR" => "ell",
"HU" => "hun",
"CZ" => "ces",
"SK" => "slk",
"RO" => "ron",
"BG" => "bul",
"RS" => "srp",
"HR" => "hrv",
"IL" => "heb",
"IR" => "fas",
"TH" => "tha",
"VN" => "vie",
"ID" => "ind",
_ => "und",
}
}
/// Every name of a work as `(lang3, name)` — what the card files under
/// `titles/{lang3}/{name}`:
///
/// - `original` under its `original_language` (`und` when unmapped/absent);
/// - `title` under [`METADATA_LANG3`], **unless it equals the original** — TMDB
/// falls back to the original name when it has no en-US translation, so `3%`
/// is filed once, as `titles/por/3%`, not also as an English name;
/// - every AKA under the language its market implies ([`market_lang3`]).
///
/// Names are normalised ([`normalize_title_name`]); blanks, TMDB's
/// `(untitled)` placeholder and exact `(lang3, name)` repeats are dropped.
pub(crate) fn title_names(
title: &str,
original: Option<&str>,
original_language: Option<&str>,
akas: &[TmdbAltTitle],
) -> Vec<(&'static str, String)> {
let mut out: Vec<(&'static str, String)> = Vec::new();
let mut push = |lang3: &'static str, name: &str| {
let name = normalize_title_name(name);
if !name.is_empty() && !out.iter().any(|(l, n)| *l == lang3 && *n == name) {
out.push((lang3, name));
}
};
let original = original.map(normalize_title_name).filter(|o| !o.is_empty());
if let Some(o) = &original {
let lang3 = original_language
.and_then(|l| iso639_1_to_3(l.trim()))
.unwrap_or("und");
push(lang3, o);
}
let title = normalize_title_name(title);
if title != "(untitled)" && original.as_deref() != Some(title.as_str()) {
push(METADATA_LANG3, &title);
}
for aka in akas {
push(market_lang3(&aka.iso_3166_1), &aka.title);
}
out
}
// `Clone` is required so the single-flight `Shared` future (whose `Output`
// must be `Clone`) can hand the same hit to every coalesced caller; serde so
// hits persist in the redb TMDB-search cache.
@@ -530,17 +675,53 @@ pub(crate) struct TmdbHit {
/// Empty for hits built from a search list (which carries no AKAs).
#[serde(default)]
pub(crate) alt_titles: Vec<String>,
/// The same AKAs **with their market**, which the card needs to file each
/// under `titles/{lang3}/{name}` ([`title_names`]). Empty on a movie-details
/// cache entry written before it existed — `Resolver::movie_hit` refetches
/// those once.
#[serde(default)]
pub(crate) akas: Vec<TmdbAltTitle>,
/// Poster candidates from the details `images` append, filed by the card as
/// `posters/{lang3}/{cid}` (METADATA_KEYS.md §6). Empty for list hits and for
/// a movie-details cache entry written before it existed — deliberately NOT a
/// self-heal trigger in `Resolver::movie_hit`: no backfill, no TMDB budget.
#[serde(default)]
pub(crate) posters: Vec<TmdbImage>,
}
impl TmdbHit {
/// TMDB `original_language` (ISO 639-1) mapped to the store's `lang3`
/// (ISO 639-3), or `None` when the language is outside the common set or
/// absent. Used to file `original_title` under `titles/{lang3}` (§3).
/// absent. Used to file `original_title` under `titles/{lang3}/{name}` (§3).
pub(crate) fn original_lang3(&self) -> Option<&'static str> {
iso639_1_to_3(self.original_language.as_deref()?.trim())
}
}
/// One poster from a details payload's `images` append — a candidate for the
/// card's `posters/{lang3}/{cid}` key-set ([`crate::card::poster_member_keys`]).
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct TmdbImage {
#[serde(default)]
pub(crate) file_path: String,
/// Language of the text printed on the poster (ISO 639-1); `None` = textless.
#[serde(default)]
pub(crate) iso_639_1: Option<String>,
#[serde(default)]
pub(crate) vote_average: f64,
#[serde(default)]
pub(crate) vote_count: u32,
}
/// The `images` object `?append_to_response=images` adds to `GET /3/{tv,movie}/{id}`.
/// Only `posters` is decoded. No `language` param is sent, so TMDB returns the
/// posters of every language.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct TmdbImages {
#[serde(default)]
pub(crate) posters: Vec<TmdbImage>,
}
/// Authoritative TV structure from `GET /3/tv/{id}`, used to bounds-check
/// a title-parsed season/episode. Only the two structural fields are
/// decoded; everything else in the (large) details payload is ignored.
@@ -550,6 +731,10 @@ impl TmdbHit {
pub(crate) struct TmdbTvDetails {
#[serde(default)]
pub(crate) number_of_seasons: u32,
/// Genre objects (`{id, name}`) — present on a details fetch, unlike the
/// bare `genre_ids` a list response carries. Free here: same payload.
#[serde(default)]
pub(crate) genres: Vec<TmdbGenre>,
/// One entry per season TMDB knows about, including season 0
/// ("Specials"). `episode_count` is the released-episode total.
#[serde(default)]
@@ -577,6 +762,10 @@ pub(crate) struct TmdbTvDetails {
/// fetch self-heals with one refetch (same pattern as the display fields).
#[serde(default)]
pub(crate) alternative_titles: Option<TmdbAltTitles>,
/// Poster candidates from `?append_to_response=images`. `None` on a cache
/// entry written before the append — NOT a self-heal trigger (no backfill).
#[serde(default)]
pub(crate) images: Option<TmdbImages>,
}
impl TmdbTvDetails {
@@ -590,8 +779,15 @@ impl TmdbTvDetails {
/// True when the AKA field was populated by the current (append-enabled)
/// decoder. A `false` on a cache hit means a pre-AKA entry → the cached
/// fetch refetches once to upgrade it (mirrors [`has_display`]).
///
/// Also `false` for an AKA list decoded before `iso_3166_1` was: the
/// search-term pick (`card::search_aka`) prefers English-market AKAs, and a
/// market-less list would silently keep TMDB's order (`3 Pourcent` over
/// `3 percent`) forever. One refetch per such entry upgrades it.
pub(crate) fn has_akas(&self) -> bool {
self.alternative_titles.is_some()
self.alternative_titles
.as_ref()
.is_some_and(TmdbAltTitles::has_markets)
}
/// The show's canonical + original + AKA titles, for the id-based-job
@@ -632,6 +828,16 @@ impl TmdbTvDetails {
.as_ref()
.map(TmdbAltTitles::all)
.unwrap_or_default(),
akas: self
.alternative_titles
.as_ref()
.map(TmdbAltTitles::entries)
.unwrap_or_default(),
posters: self
.images
.as_ref()
.map(|i| i.posters.clone())
.unwrap_or_default(),
})
}
}
@@ -659,17 +865,42 @@ pub(crate) struct TmdbAltTitles {
pub(crate) struct TmdbAltTitle {
#[serde(default)]
pub(crate) title: String,
/// The market the AKA is used in (`US`, `FR`, …). Empty on a cache entry
/// written before it was decoded — ordering then falls back to TMDB's.
#[serde(default)]
pub(crate) iso_3166_1: String,
}
impl TmdbAltTitles {
/// Was this list decoded with markets? TMDB stamps `iso_3166_1` on every
/// AKA, so a non-empty list where none carries one is a pre-market cache
/// entry. An empty list is complete ("fetched, no AKAs").
pub(crate) fn has_markets(&self) -> bool {
let mut titles = self.results.iter().chain(self.titles.iter()).peekable();
titles.peek().is_none() || titles.any(|t| !t.iso_3166_1.is_empty())
}
/// The non-empty AKA titles across both the TV (`results`) and movie
/// (`titles`) shapes.
/// (`titles`) shapes, **English-market (`US`, `GB`) titles first**, TMDB's
/// order otherwise. The relevance guard only asks "any of these?", so the
/// order is free for it; it matters to the search-term AKA pick
/// (`card::search_aka`), where `3 percent` (US) must beat `3 Pourcent` (FR).
pub(crate) fn all(&self) -> Vec<String> {
self.results
.iter()
.chain(self.titles.iter())
.map(|t| t.title.trim().to_string())
.filter(|t| !t.is_empty())
self.entries().into_iter().map(|t| t.title).collect()
}
/// [`Self::all`] with each AKA's market kept — same order, same filtering
/// (titles trimmed, blanks dropped).
pub(crate) fn entries(&self) -> Vec<TmdbAltTitle> {
let mut titles: Vec<&TmdbAltTitle> = self.results.iter().chain(self.titles.iter()).collect();
titles.sort_by_key(|t| !matches!(t.iso_3166_1.as_str(), "US" | "GB"));
titles
.into_iter()
.map(|t| TmdbAltTitle {
title: t.title.trim().to_string(),
iso_3166_1: t.iso_3166_1.trim().to_string(),
})
.filter(|t| !t.title.is_empty())
.collect()
}
}
@@ -691,6 +922,9 @@ pub(crate) struct TmdbExternalIds {
#[derive(Debug, Clone, serde::Deserialize)]
pub(crate) struct TmdbMovieDetails {
pub(crate) id: u64,
/// See [`TmdbTvDetails::genres`].
#[serde(default)]
pub(crate) genres: Vec<TmdbGenre>,
#[serde(default)]
pub(crate) title: Option<String>,
#[serde(default)]
@@ -705,6 +939,9 @@ pub(crate) struct TmdbMovieDetails {
pub(crate) poster_path: Option<String>,
#[serde(default)]
pub(crate) alternative_titles: Option<TmdbAltTitles>,
/// See [`TmdbTvDetails::images`].
#[serde(default)]
pub(crate) images: Option<TmdbImages>,
}
impl TmdbMovieDetails {
@@ -719,6 +956,12 @@ impl TmdbMovieDetails {
.as_ref()
.map(TmdbAltTitles::all)
.unwrap_or_default();
let akas = self
.alternative_titles
.as_ref()
.map(TmdbAltTitles::entries)
.unwrap_or_default();
let posters = self.images.map(|i| i.posters).unwrap_or_default();
TmdbHit {
tmdbid: self.id,
title: self.title.unwrap_or_else(|| "(untitled)".to_string()),
@@ -729,6 +972,8 @@ impl TmdbMovieDetails {
poster_path: self.poster_path,
genre_ids: Vec::new(),
alt_titles,
akas,
posters,
}
}
}
+10 -1
View File
@@ -64,7 +64,7 @@ fn tv_details_json() -> serde_json::Value {
"poster_path": "/poster.jpg",
"number_of_seasons": 1,
"seasons": [{"season_number": 1, "episode_count": 28}],
"alternative_titles": { "results": [{"title": "Sousou no Frieren"}] }
"alternative_titles": { "results": [{"iso_3166_1": "JP", "title": "Sousou no Frieren"}] }
})
}
@@ -148,6 +148,15 @@ async fn query_returns_a_card_with_both_axes_and_the_id_bag() {
assert_eq!(f["fileType"], "card");
assert_eq!(f["contentKind"], "series");
assert_eq!(f["title"], "Frieren: Beyond Journey's End");
// Every name as the language-nested key-set: original (ja), the en-US
// title, and the JP-market AKA.
for key in [
"titles/jpn/\u{845}\u{9001}\u{306e}\u{30d5}\u{30ea}\u{30fc}\u{30ec}\u{30f3}",
"titles/eng/Frieren: Beyond Journey's End",
"titles/jpn/Sousou no Frieren",
] {
assert_eq!(f.get(key).map(String::as_str), Some("true"), "{key}");
}
// The id bag — phase 2 selects from this rather than assuming `tmdbid`.
assert_eq!(f["tmdbid"], "95479");
assert_eq!(f["tvdbid"], "367189");