inital commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
target
|
||||||
Generated
+2177
File diff suppressed because it is too large
Load Diff
+18
@@ -0,0 +1,18 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/*", "feeder-plugin/*"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.85"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/worph/meta-feeder-tribler"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Safety net for the yanked transitive `core2` dep, kept identical to the
|
||||||
|
# meta-gateway / meta-feeder-torrent workspaces. Harmless (an unused-patch
|
||||||
|
# warning) if the dep graph doesn't pull it; required if it does. Remove once
|
||||||
|
# multihash-codetable drops the core2 dependency upstream.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[patch.crates-io]
|
||||||
|
core2 = { git = "https://github.com/bbqsrc/core2", rev = "545e84b" }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
[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"] }
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
//! 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
//! 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("PROWLARR_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
@@ -0,0 +1,574 @@
|
|||||||
|
//! 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 generic **Newznab release** locator (`nzb-release`).
|
||||||
|
/// MetaMesh-private, adjacent to the torrent-file codecs (`0x1002` is taken by
|
||||||
|
/// [`BTIH_V2_FILE_CODEC`]). It identifies an indexer *listing* (host + release
|
||||||
|
/// id), so it is derivable at search time with **no `.nzb` download**. See
|
||||||
|
/// `USENET-GATEWAY-STUDY.md` §13. (The v1 content-stable `nzb-posting` `0x1003`
|
||||||
|
/// variant — `sha256(message-ids)` — was removed; only this one is emitted.)
|
||||||
|
pub const NZB_RELEASE_CODEC: u64 = 0x1004;
|
||||||
|
|
||||||
|
/// Encode an `nzb-release` CID from a Newznab indexer's host + bare release id
|
||||||
|
/// (the 32-hex `<guid>` id). `sha2-256(host ‖ "\n" ‖ id)` — host-namespaced so
|
||||||
|
/// the same movie on two indexers (different postings) yields different cids.
|
||||||
|
/// Deterministic + opaque, same wire house-style as the other locator codecs.
|
||||||
|
pub fn compute_nzb_release_cid(host: &str, release_id: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(host.as_bytes());
|
||||||
|
hasher.update(b"\n");
|
||||||
|
hasher.update(release_id.as_bytes());
|
||||||
|
let digest: [u8; 32] = hasher.finalize().into();
|
||||||
|
|
||||||
|
// CIDv1: [version=0x01][codec varint][multihash code varint][len=0x20][digest]
|
||||||
|
let mut wire = Vec::with_capacity(1 + 4 + 1 + 32);
|
||||||
|
wire.push(0x01);
|
||||||
|
write_pb_varint(NZB_RELEASE_CODEC, &mut wire);
|
||||||
|
write_pb_varint(NZB_RELEASE_CODEC, &mut wire);
|
||||||
|
wire.push(0x20);
|
||||||
|
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 nzb_release_cid_wire_and_properties() {
|
||||||
|
let cid = compute_nzb_release_cid("api.nzb.life", "485c52f078b580667a01ac34a1cef6c2");
|
||||||
|
assert!(cid.starts_with('b'));
|
||||||
|
// Deterministic.
|
||||||
|
assert_eq!(
|
||||||
|
cid,
|
||||||
|
compute_nzb_release_cid("api.nzb.life", "485c52f078b580667a01ac34a1cef6c2")
|
||||||
|
);
|
||||||
|
// Host-namespaced: same id, different host → different cid.
|
||||||
|
assert_ne!(
|
||||||
|
cid,
|
||||||
|
compute_nzb_release_cid("api.nzbgeek.info", "485c52f078b580667a01ac34a1cef6c2")
|
||||||
|
);
|
||||||
|
// Explicit wire form: [01][84 20][84 20][20][sha256("host\nid")].
|
||||||
|
let digest: [u8; 32] =
|
||||||
|
Sha256::digest(b"api.nzb.life\n485c52f078b580667a01ac34a1cef6c2").into();
|
||||||
|
let mut want = vec![0x01, 0x84, 0x20, 0x84, 0x20, 0x20];
|
||||||
|
want.extend_from_slice(&digest);
|
||||||
|
assert_eq!(cid, format!("b{}", base32_lower_no_padding(&want)));
|
||||||
|
// Distinct codec from the bt-file codecs in the 0x100x neighborhood.
|
||||||
|
assert_ne!(cid, compute_bt_v1_file_cid(&[0xAB; 20], 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! `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};
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
//! 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! 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` `0x1004`. Locator like `BtV1File`: opaque to bitswap,
|
||||||
|
/// redeemed to bytes only by a credentialed meta-share peer, which grabs the
|
||||||
|
/// `.nzb` via the `{base, id}` descriptor stored at `nzb:<cid>` KV.
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key-set prefix for the provenance `source/<label>` field (METADATA_KEYS §5).
|
||||||
|
/// Each member is its own hash field (`source/gateway:nyaa.si = "true"`) so two
|
||||||
|
/// producers that reach the same content by different routes union without a
|
||||||
|
/// last-writer-wins clobber. The `<label>` uses `:` internally (not `/`, which
|
||||||
|
/// is the key-set path separator) — e.g. `gateway:nyaa.si`, `gateway:tribler`.
|
||||||
|
pub const SOURCE_KEYSET_PREFIX: &str = "source/";
|
||||||
|
|
||||||
|
/// Stamp the SDK's default provenance member — `source/gateway:<upstream_id>` —
|
||||||
|
/// onto a record's fields, but **only if it carries no `source/*` member yet**.
|
||||||
|
/// This gives every gateway hit a provenance label for free (so meta-watch never
|
||||||
|
/// falls back to "unknown" for a feeder record), while a plugin that knows a
|
||||||
|
/// finer origin (torznab → the specific Prowlarr indexer) stamps its own
|
||||||
|
/// `source/gateway:<indexer>` first and this default then no-ops.
|
||||||
|
pub fn stamp_default_source(
|
||||||
|
fields: &mut std::collections::BTreeMap<String, String>,
|
||||||
|
upstream_id: &str,
|
||||||
|
) {
|
||||||
|
if fields.keys().any(|k| k.starts_with(SOURCE_KEYSET_PREFIX)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fields.insert(
|
||||||
|
format!("{SOURCE_KEYSET_PREFIX}gateway:{upstream_id}"),
|
||||||
|
"true".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
//! 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"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
//! `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::{stamp_default_source, 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 mut records = plugin
|
||||||
|
.handle_query(&req.query, req.max_results as usize)
|
||||||
|
.await
|
||||||
|
.map_err(gateway_error_response)?;
|
||||||
|
for r in &mut records {
|
||||||
|
stamp_default_source(&mut r.fields, &req.upstream_id);
|
||||||
|
}
|
||||||
|
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)?;
|
||||||
|
// Stamp the default provenance member on each streamed `Base` record (a
|
||||||
|
// no-op when the plugin already carries a finer `source/*`, e.g. torznab's
|
||||||
|
// per-indexer label). `EnrichPatch`/`Drop`/`Done` frames pass through: the
|
||||||
|
// base they refer to was already stamped.
|
||||||
|
let upstream_id = req.upstream_id.clone();
|
||||||
|
let events = events.map(move |ev| match ev {
|
||||||
|
GatewaySearchEvent::Base(mut rec) => {
|
||||||
|
stamp_default_source(&mut rec.fields, &upstream_id);
|
||||||
|
GatewaySearchEvent::Base(rec)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
});
|
||||||
|
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 mut outcomes = plugin
|
||||||
|
.compute_outcomes(&req.record_id)
|
||||||
|
.await
|
||||||
|
.map_err(gateway_error_response)?;
|
||||||
|
for o in &mut outcomes {
|
||||||
|
if let Some(rec) = o.record.as_mut() {
|
||||||
|
stamp_default_source(&mut rec.fields, &req.upstream_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! 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"})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[package]
|
||||||
|
name = "tribler-feeder"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
# Tribler feeder sidecar: bridges the decentralized Tribler (IPv8 overlay)
|
||||||
|
# torrent network into the MetaMesh network. Split out of `meta-feeder-torrent`
|
||||||
|
# so it is a single-plugin feeder (its dashboard config panel resolves cleanly,
|
||||||
|
# unlike a multi-plugin feeder whose one /config surface can only expose one
|
||||||
|
# plugin). Carries a copy of the shared "torrent-core" TMDB catalog-discovery
|
||||||
|
# stack (the part that CANNOT be a container plugin — home-row browse has no
|
||||||
|
# per-CID plugin equivalent); the librqbit BitTorrent fetch stays in
|
||||||
|
# meta-feeder-torrent (tribler enumerates files via the Tribler core's metainfo,
|
||||||
|
# not librqbit). Like every feeder it is meta-core-free + blockstore-free.
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "tribler_feeder"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "tribler-feeder"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
meta-feeder-sdk = { path = "../../crates/meta-feeder-sdk" }
|
||||||
|
|
||||||
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
axum = { version = "0.7", features = ["json"] }
|
||||||
|
anyhow = "1"
|
||||||
|
async-trait = "0.1"
|
||||||
|
thiserror = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
|
||||||
|
bytes = "1"
|
||||||
|
futures = "0.3"
|
||||||
|
regex = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wiremock = "0.6"
|
||||||
|
tempfile = "3"
|
||||||
|
tokio = { version = "1.40", features = ["full", "test-util"] }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# =============================================================================
|
||||||
|
# tribler-feeder — Tribler (IPv8 overlay) feeder sidecar. Multi-stage build.
|
||||||
|
#
|
||||||
|
# Build context must be this repo root (packages/plugins/meta-feeder-tribler) so
|
||||||
|
# the `meta-feeder-sdk` path dependency resolves. Same BuildKit cache-mount
|
||||||
|
# strategy as the other feeders. No librqbit here (tribler enumerates files via
|
||||||
|
# the Tribler core's metainfo), so the cold build is lighter than the torrent
|
||||||
|
# feeder's.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FROM rust:1.89-slim-bookworm AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||||
|
COPY crates/ crates/
|
||||||
|
COPY feeder-plugin/ feeder-plugin/
|
||||||
|
|
||||||
|
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
|
||||||
|
--mount=type=cache,id=meta-feeder-tribler-target,target=/build/target \
|
||||||
|
cargo build --release --bin tribler-feeder \
|
||||||
|
&& cp target/release/tribler-feeder /tribler-feeder
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /tribler-feeder /usr/local/bin/tribler-feeder
|
||||||
|
|
||||||
|
RUN useradd --system --uid 10001 --no-create-home metafeeder \
|
||||||
|
&& mkdir -p /data/meta-feeder \
|
||||||
|
&& chown metafeeder:metafeeder /data/meta-feeder
|
||||||
|
|
||||||
|
USER metafeeder
|
||||||
|
WORKDIR /data/meta-feeder
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENV META_FEEDER_HTTP_LISTEN=0.0.0.0:8080 \
|
||||||
|
META_FEEDER_STATE_DIR=/data/meta-feeder \
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/tribler-feeder"]
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
//! Shared constants for the torznab plugin module.
|
||||||
|
//!
|
||||||
|
//! These symbols are referenced across `mod.rs`, `tmdb.rs` and `enrich.rs`
|
||||||
|
//! but were never committed when the plugin was split from a single file into
|
||||||
|
//! this directory module — the crate failed to compile (`E0425: cannot find
|
||||||
|
//! value ...`). Values below are sensible defaults; review them against the
|
||||||
|
//! intended torznab/TMDB contract (endpoints + env-var names especially).
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// User-Agent for outbound HTTP from the torznab plugin (TMDB + indexers).
|
||||||
|
/// Mirrors the per-plugin convention in `plugins/{arxiv,giphy,gutenberg}.rs`.
|
||||||
|
pub(crate) const USER_AGENT: &str = concat!(
|
||||||
|
"meta-share/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" (gateway:torznab)"
|
||||||
|
);
|
||||||
|
|
||||||
|
/// TMDB REST API base (v3).
|
||||||
|
pub(crate) const TMDB_API_BASE: &str = "https://api.themoviedb.org/3";
|
||||||
|
/// TMDB image CDN base; callers append a size segment (e.g. `/w500/<path>`).
|
||||||
|
pub(crate) const TMDB_IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
|
||||||
|
/// TMDB poster size segment inserted between [`TMDB_IMAGE_BASE`] and the
|
||||||
|
/// poster path. Mandatory — TMDB 404s a path with no size. `w500` is the
|
||||||
|
/// poster-grid sweet spot (≈122 KB vs `original`'s multi-MB).
|
||||||
|
pub(crate) const TMDB_POSTER_SIZE: &str = "w500";
|
||||||
|
|
||||||
|
/// Generic outbound HTTP timeout (seconds). Raised 100x (30 -> 3000) for
|
||||||
|
/// completeness — the gateway runs inside a long, streaming search, not a
|
||||||
|
/// sub-5s one; an outbound call that's still making progress shouldn't be cut.
|
||||||
|
pub(crate) const HTTP_TIMEOUT_SECS: u64 = 3000;
|
||||||
|
/// TMDB search request timeout (seconds). Raised 100x (10 -> 1000) — TMDB
|
||||||
|
/// cross-ref enrichment is part of result completeness and needs time.
|
||||||
|
pub(crate) const TMDB_SEARCH_TIMEOUT_SECS: u64 = 1000;
|
||||||
|
/// TMDB poster download timeout (seconds). Raised 100x (30 -> 3000).
|
||||||
|
pub(crate) const TMDB_POSTER_TIMEOUT_SECS: u64 = 3000;
|
||||||
|
/// Default Retry-After (seconds) applied when TMDB returns HTTP 429 with no
|
||||||
|
/// (or an unparseable) `Retry-After` header.
|
||||||
|
pub(crate) const TMDB_RATE_LIMIT_DEFAULT_RETRY_SECS: u64 = 5;
|
||||||
|
/// Maximum time to wait for a TMDB enrichment permit before giving up (seconds).
|
||||||
|
/// Raised 100x (30 -> 3000) — under a deep search many records contend for the
|
||||||
|
/// 4 enrichment permits; giving up early drops enrichment from the tail.
|
||||||
|
pub(crate) const TMDB_ENRICH_WAIT_DEADLINE_SECS: u64 = 3000;
|
||||||
|
/// Maximum number of concurrent TMDB enrichment requests.
|
||||||
|
pub(crate) const TMDB_ENRICH_CONCURRENCY: usize = 4;
|
||||||
|
/// Per-indexer torznab query timeout (seconds). This is the cap that actually
|
||||||
|
/// guillotines slow trackers on the primary *streaming* gateway path (the 5s
|
||||||
|
/// `GATEWAY_CALL_TIMEOUT` only bounds the one-shot fallback). Raised 100x
|
||||||
|
/// (3 -> 300) for completeness so a slow-but-alive indexer is waited for
|
||||||
|
/// rather than silently dropped from the merge. Stays <= the (also-raised)
|
||||||
|
/// 500s one-shot `GATEWAY_CALL_TIMEOUT`. NOTE: `search_probe_timeout()` reuses
|
||||||
|
/// this value, so reachability probes against a *dead* indexer now hang up to
|
||||||
|
/// 300s — acceptable for a completeness-first posture, but the dual use is why
|
||||||
|
/// this isn't split into its own probe constant yet.
|
||||||
|
pub(crate) const PER_INDEXER_QUERY_TIMEOUT_SECS: u64 = 300;
|
||||||
|
|
||||||
|
/// Max concurrent `.torrent` downloads while resolving one indexer's results.
|
||||||
|
/// Bounds the socket burst when a whole result page is torrent-only. Kept low
|
||||||
|
/// (4) because the resolve fetches hit public trackers/torrent caches that
|
||||||
|
/// reset connections under a wide burst — the same load that makes Prowlarr's
|
||||||
|
/// own indexer calls flake. Indexer *search* GETs are now serialized one at a
|
||||||
|
/// time through the shared [`INDEXER_LIMITER_RPS`] token bucket, so the peak
|
||||||
|
/// socket count is just this many (the in-flight page's resolves), not a
|
||||||
|
/// product across concurrent indexers.
|
||||||
|
pub(crate) const TORRENT_RESOLVE_CONCURRENCY: usize = 4;
|
||||||
|
|
||||||
|
/// Defensive cap on how many torrent-only items we attempt to resolve per
|
||||||
|
/// indexer response. The rest are dropped (logged) — at search latency,
|
||||||
|
/// resolving an unbounded page of `.torrent` files would blow the per-indexer
|
||||||
|
/// budget. A page rarely needs more than this many resolved results anyway.
|
||||||
|
pub(crate) const TORRENT_RESOLVE_MAX_PER_INDEXER: usize = 30;
|
||||||
|
|
||||||
|
/// Wall-clock budget for the whole torrent-resolution phase of a single
|
||||||
|
/// indexer call. Items not resolved within it are dropped; items resolved so
|
||||||
|
/// far are kept (partial). The gateway returns as soon as resolution
|
||||||
|
/// *completes* — the budget is only the cutoff for a page that can't finish.
|
||||||
|
///
|
||||||
|
/// **Sized for a long, streaming search, not a sub-5s one.** meta-share's
|
||||||
|
/// primary gateway path is a streaming substream (`stream_one_gateway_peer`)
|
||||||
|
/// with **no per-call deadline** — it forwards each hit to the client SSE as
|
||||||
|
/// it arrives until the gateway sends `Done`. (The 5 s `GATEWAY_CALL_TIMEOUT`
|
||||||
|
/// is only the one-shot fallback for gateways that don't speak the streaming
|
||||||
|
/// protocol.) So this can be generous: the binding constraint is meta-share's
|
||||||
|
/// `SEARCH_TIMEOUT_SECS` silence window (set to match in the dev compose), not
|
||||||
|
/// a hard call timeout. 5 min is a safety ceiling on a pathological page;
|
||||||
|
/// a normal XXX page (≤ `TORRENT_RESOLVE_MAX_PER_INDEXER` items at ~1.9 s
|
||||||
|
/// each, 8-wide) finishes in well under 10 s and the gateway returns then.
|
||||||
|
pub(crate) const TORRENT_RESOLVE_BUDGET: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Ceiling on a downloaded `.torrent` body. Real torrent files are KBs; this
|
||||||
|
/// guards against a `<link>` that points at an HTML details page or a
|
||||||
|
/// mislabeled giant download — we skip (don't buffer) anything larger.
|
||||||
|
pub(crate) const TORRENT_FILE_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Magnet BEP-9 `list_only` file-list fallback knobs (packs only) — see
|
||||||
|
/// [`crate::bt::TorznabBt::prefetch_torrent_filelists`].
|
||||||
|
///
|
||||||
|
/// HTTP `.torrent` download is always tried first (faster, swarm-free). But
|
||||||
|
/// magnet-only indexers (e.g. Nyaa.si) expose no `.torrent` URL, so a season
|
||||||
|
/// **pack** from them never gets a file list and its per-episode records ship
|
||||||
|
/// without a streamable `cid_btih_v1_file` (see `expand_record_streaming`).
|
||||||
|
/// For packs only — a single file derives its CID from the infohash alone and
|
||||||
|
/// needs no file list — fall back to a bounded BEP-9 `list_only` swarm probe
|
||||||
|
/// off the magnet. Swarm peer/DHT discovery is far slower (and often fruitless
|
||||||
|
/// for dead torrents) than an HTTP fetch, so these caps are deliberately tight:
|
||||||
|
/// the cached result makes the *next* search instant, so a partial pass is fine.
|
||||||
|
pub(crate) const TORRENT_LISTONLY_MAGNET_MAX: usize = 6;
|
||||||
|
/// Concurrent magnet enumerations. Lower than [`TORRENT_RESOLVE_CONCURRENCY`]
|
||||||
|
/// because each holds a live librqbit torrent + peer connections, not a single
|
||||||
|
/// short HTTP GET.
|
||||||
|
pub(crate) const TORRENT_LISTONLY_MAGNET_CONCURRENCY: usize = 2;
|
||||||
|
/// Per-magnet wall-clock cap on the BEP-9 enumeration (tighter than
|
||||||
|
/// `list_files`' own internal full-fetch budget, which is sized for downloads).
|
||||||
|
pub(crate) const TORRENT_LISTONLY_MAGNET_PER_ITEM: Duration = Duration::from_secs(25);
|
||||||
|
/// Overall deadline for the whole magnet-fallback pass; whatever resolved by
|
||||||
|
/// then is kept, the rest fall through to the title-only (un-CID'd) expansion.
|
||||||
|
pub(crate) const TORRENT_LISTONLY_MAGNET_BUDGET: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// **Test-only.** When truthy (`1`/`true`/`yes`/`on`), a torznab record that
|
||||||
|
/// fails TMDB enrichment is stubbed with just enough metadata (a `poster`, a
|
||||||
|
/// `description`, and video kind if missing) to pass meta-watch's quality
|
||||||
|
/// gate — simulating "content pollution" so an operator can validate the
|
||||||
|
/// default-on adult-content filter against records that carry `categories/XXX`
|
||||||
|
/// but never match TMDB. **Off by default; never enable in production** — it
|
||||||
|
/// fabricates metadata. See [`super::enrich::stub_unmatched_patch`].
|
||||||
|
pub(crate) const ENV_STUB_UNMATCHED: &str = "META_GATEWAY_PROWLARR_STUB_UNMATCHED";
|
||||||
|
|
||||||
|
/// Timeout used when probing an indexer's search path for reachability.
|
||||||
|
pub(crate) fn search_probe_timeout() -> Duration {
|
||||||
|
Duration::from_secs(PER_INDEXER_QUERY_TIMEOUT_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default number of TMDB anchors resolved per query (top-N by popularity).
|
||||||
|
/// For a vague keyword ("black") TMDB returns many real shows; we anchor on the
|
||||||
|
/// top-N rather than collapsing onto the single most popular (the old
|
||||||
|
/// `principal_confident` behaviour that hijacked the query). Each anchor becomes
|
||||||
|
/// its own paged search per indexer, so this multiplies the indexer request
|
||||||
|
/// count — the serialized [`INDEXER_LIMITER_RPS`] bucket keeps that gentle.
|
||||||
|
/// Overridable via `META_GATEWAY_PROWLARR_ANCHOR_TOP_N`.
|
||||||
|
pub(crate) const DEFAULT_ANCHOR_TOP_N: usize = 10;
|
||||||
|
|
||||||
|
/// How many ranked principal-search anchors to persist per query in the redb
|
||||||
|
/// cache. Sized comfortably above any reasonable top-N so flipping the knob
|
||||||
|
/// never needs a cache wipe — reads slice the cached list to the configured N.
|
||||||
|
pub(crate) const CACHED_PRINCIPAL_DEPTH: usize = 30;
|
||||||
|
|
||||||
|
/// Indexer search-request rate limiter (a [`crate::tmdb_budget::TmdbBudget`]
|
||||||
|
/// token bucket shared across the whole torznab fan-out). Public trackers and
|
||||||
|
/// Prowlarr reset/429 under bursts, so search GETs are serialized one at a time
|
||||||
|
/// at ~`INDEXER_LIMITER_RPS` grants/sec with a small `INDEXER_LIMITER_BURST`.
|
||||||
|
/// Adaptive: a 429 (or transient reset/5xx) pauses the bucket via `note_429`.
|
||||||
|
pub(crate) const INDEXER_LIMITER_RPS: f64 = 1.0;
|
||||||
|
/// Burst capacity of the indexer rate limiter (see [`INDEXER_LIMITER_RPS`]).
|
||||||
|
pub(crate) const INDEXER_LIMITER_BURST: f64 = 3.0;
|
||||||
|
/// Backoff applied to the indexer limiter on a transient (connection-reset /
|
||||||
|
/// 5xx) indexer failure that carries no explicit `Retry-After` window.
|
||||||
|
pub(crate) const INDEXER_LIMITER_TRANSIENT_BACKOFF: Duration = Duration::from_secs(2);
|
||||||
|
/// Deadline for acquiring an indexer-limiter token before a page request.
|
||||||
|
/// Generous (completeness-first; meta-share waits its silence window) so a
|
||||||
|
/// token effectively always arrives — `DeadlineExceeded` just drops that page.
|
||||||
|
pub(crate) const INDEXER_LIMITER_WAIT_DEADLINE_SECS: u64 = 3000;
|
||||||
|
|
||||||
|
/// Minimum fraction of a (cleaned) release title's tokens that must be covered
|
||||||
|
/// by an anchor's title before a text-anchored job stamps that anchor's tmdbid
|
||||||
|
/// onto the release. Guards against text false-positives — a release that
|
||||||
|
/// merely *mentions* the anchor title (e.g. "Turning Mecard … Black Mirror",
|
||||||
|
/// 2/≈9 ≈ 0.22) stays unanchored and enriches via its own fuzzy lookup, while a
|
||||||
|
/// real "Black Mirror S05E01 …" (cleans to "Black Mirror", 2/2 = 1.0) is
|
||||||
|
/// stamped. Only applies to fuzzy text jobs; id-based (tvdbid/tmdbid) jobs are
|
||||||
|
/// trusted and bypass the guard. See [`super::anchor::apply_record_tag`].
|
||||||
|
pub(crate) const ANCHOR_TITLE_MIN_COVERAGE: f64 = 0.6;
|
||||||
|
|
||||||
|
/// Backoff before the single retry [`super::TorznabPlugin::send_get_with_retry`]
|
||||||
|
/// makes after a transient (connection-reset / 5xx) indexer failure. Short —
|
||||||
|
/// just enough to let a momentarily-overloaded indexer or a stale pooled
|
||||||
|
/// connection clear before the second attempt.
|
||||||
|
pub(crate) const HTTP_RETRY_BACKOFF: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
|
/// Defensive ceiling on the per-season fan-out loop. A show with more seasons
|
||||||
|
/// than this (none real; guards a corrupt `number_of_seasons`) only fans out
|
||||||
|
/// the first `MAX_ANCHORED_SEASONS`.
|
||||||
|
pub(crate) const MAX_ANCHORED_SEASONS: u32 = 50;
|
||||||
|
|
||||||
|
/// Per-indexer fetch page size for the depth strategy. Clamped to each
|
||||||
|
/// indexer's declared Prowlarr `capabilities.limitsMax`. Kept small/light so a
|
||||||
|
/// single Prowlarr `indexerIds=<one>` call behaves like the Prowlarr UI (one
|
||||||
|
/// tracker, a modest page) instead of one heavy `indexerIds=-2&limit=2500`
|
||||||
|
/// aggregate — that oversized limit was both futile (every public tracker caps
|
||||||
|
/// at ~100) and the source of the gateway→Prowlarr connection resets under load.
|
||||||
|
pub(crate) const TORZNAB_PAGE_SIZE: usize = 100;
|
||||||
|
|
||||||
|
/// Bounded buffer between the streaming search producer task and the SSE-bound
|
||||||
|
/// receiver stream. Backpressure: when the consumer (meta-share) is slow the
|
||||||
|
/// producer's `send().await` parks rather than piling events in memory.
|
||||||
|
pub(crate) const GATEWAY_STREAM_BUFFER: usize = 256;
|
||||||
|
|
||||||
|
/// Max results fetched **per indexer, per search** (depth cap). Bounds both the
|
||||||
|
/// single-shot request for a non-paginating indexer and the offset-paging loop
|
||||||
|
/// for one that supports pagination. Tunable; the merge across indexers + the
|
||||||
|
/// caller's display `limit` truncate still apply on top. With today's 5 public
|
||||||
|
/// trackers (all `limitsMax=100`, no pagination) the effective per-indexer
|
||||||
|
/// fetch is 100, so 5×100 ≈ 500 merged — which is where this default lands.
|
||||||
|
pub(crate) const TORZNAB_MAX_DEPTH: usize = 500;
|
||||||
|
|
||||||
|
/// Ceiling on how many records one torrent may fan out to when a pack is
|
||||||
|
/// unpacked into per-file episodes (or range-inferred). A guard against a
|
||||||
|
/// pathological 1000-file torrent flooding one search; well above any real
|
||||||
|
/// season pack. Mirrors the tribler feeder's identical cap.
|
||||||
|
pub(crate) const MAX_FANOUT_FILES: usize = 100;
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
//! Keyword-less catalog discovery branch.
|
||||||
|
//!
|
||||||
|
//! meta-share has no catalog/browse primitive, so a Stremio-style client
|
||||||
|
//! (meta-watch) can't ask "what's popular" — it can only search by keyword.
|
||||||
|
//! This branch answers a **keyword-less** gateway query whose intent is carried
|
||||||
|
//! by structured filters, by calling TMDB's catalog endpoints. TMDB is the
|
||||||
|
//! catalog oracle; the results are clean, ranked entries, each a distinct TMDB
|
||||||
|
//! entity by construction (so the per-hit anchor-stamping that causes the
|
||||||
|
//! wrong-poster bug on the indexer path simply can't happen here).
|
||||||
|
//!
|
||||||
|
//! ## Query dialect
|
||||||
|
//!
|
||||||
|
//! - **mode** (one of) — `popular:true` → `/{movie,tv}/popular`,
|
||||||
|
//! `trending:true` → `/trending/{movie,tv}/week`,
|
||||||
|
//! `top_rated:true` → `/{movie,tv}/top_rated`.
|
||||||
|
//! - **kind** (required, also drives meta-share routing) — `contentKind:movie`
|
||||||
|
//! → movies, `contentKind:episode` (or `tv`/`tvshow`) → TV.
|
||||||
|
//! - **anime** (optional) — `anime:true` switches to `/discover/{movie,tv}`
|
||||||
|
//! filtered to the TMDB *anime* keyword, sorted to match the mode. Useful when
|
||||||
|
//! the only reachable indexer is an anime tracker (Nyaa): anime catalog titles
|
||||||
|
//! actually have torrents, so the rows populate.
|
||||||
|
//!
|
||||||
|
//! ## Seeds, not files
|
||||||
|
//!
|
||||||
|
//! The records emitted here are **metadata-only seeds** — `title` + `tmdbid`
|
||||||
|
//! (+ year) — *not* playable releases. There is no torznab fan-out, no poster
|
||||||
|
//! fetch, and no `compute_outcomes`: meta-watch consumes a seed only to read its
|
||||||
|
//! title/tmdbid, then issues its *own* anchored per-title search
|
||||||
|
//! (`<title> fileType:video tmdbid:NNN`) to map a real file behind the card.
|
||||||
|
//!
|
||||||
|
//! ## Why the marker stamps are load-bearing
|
||||||
|
//!
|
||||||
|
//! Both the gateway's own dispatcher-side filter and meta-share's consumer-side
|
||||||
|
//! `record_matches` re-apply the query's structured filters to every returned
|
||||||
|
//! record, and a **missing field fails the match** (drop). So each seed must
|
||||||
|
//! echo *every* discovery filter the query carried (`popular`/`trending`/
|
||||||
|
//! `top_rated`, `contentKind`, and `anime` when present) or it is dropped at one
|
||||||
|
//! of the two tiers.
|
||||||
|
|
||||||
|
use futures::channel::mpsc::Sender;
|
||||||
|
use futures::SinkExt;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use meta_feeder_sdk::query::{GatewayQuery, GatewaySearchEvent};
|
||||||
|
use crate::tmdb_budget::{Lease, TmdbBudget};
|
||||||
|
use meta_feeder_sdk::types::DiscoveryRecord;
|
||||||
|
|
||||||
|
use crate::consts::TMDB_ENRICH_WAIT_DEADLINE_SECS;
|
||||||
|
use crate::tmdb::{TmdbCall, TmdbClient, TmdbHit, TmdbKind};
|
||||||
|
|
||||||
|
/// TMDB keyword id for "anime" — the `anime:true` filter narrows `/discover` to
|
||||||
|
/// it so an anime-only tracker actually has matching torrents.
|
||||||
|
const TMDB_ANIME_KEYWORD: &str = "210024";
|
||||||
|
|
||||||
|
/// The catalog-list flavour a discovery query asks for. The marker filter that
|
||||||
|
/// selected it (`popular`/`trending`/`top_rated`) is echoed onto every seed so
|
||||||
|
/// the record survives `record_matches` on both tiers.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum DiscoveryMode {
|
||||||
|
Popular,
|
||||||
|
Trending,
|
||||||
|
TopRated,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscoveryMode {
|
||||||
|
/// The query-filter key that selects this mode (and is stamped on seeds).
|
||||||
|
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 — the dedicated
|
||||||
|
/// 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-discovery query the branch should
|
||||||
|
/// answer instead of the indexer fan-out: no free text and a truthy
|
||||||
|
/// mode marker. A `contentKind` is still required to route at all (meta-share
|
||||||
|
/// keys gateway routing on it) and to pick the movie-vs-TV endpoint.
|
||||||
|
pub(crate) fn is_discovery_query(query: &GatewayQuery) -> bool {
|
||||||
|
query.free_text.trim().is_empty() && 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.
|
||||||
|
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),
|
||||||
|
"episode" | "tvshow" | "tv" => return Some(TmdbKind::Tv),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TMDB returns this many results per catalog page; used to translate a desired
|
||||||
|
/// seed count into a page count (`discover_records` walks pages until it has
|
||||||
|
/// `max_results` hits).
|
||||||
|
const TMDB_PAGE_SIZE: usize = 20;
|
||||||
|
|
||||||
|
/// Build the `path_and_query` for [`TmdbClient::discovery_list`] from the mode,
|
||||||
|
/// kind, anime flag, and 1-based `page`.
|
||||||
|
fn build_path_and_query(mode: DiscoveryMode, kind: TmdbKind, anime: bool, page: u32) -> String {
|
||||||
|
let seg = match kind {
|
||||||
|
TmdbKind::Movie => "movie",
|
||||||
|
TmdbKind::Tv => "tv",
|
||||||
|
};
|
||||||
|
if anime {
|
||||||
|
// `/discover` is the only endpoint that takes a keyword filter. Approx
|
||||||
|
// top-rated with a vote-count floor so a 1-vote 10.0 doesn't win.
|
||||||
|
let floor = match mode {
|
||||||
|
DiscoveryMode::TopRated => "&vote_count.gte=200",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"discover/{seg}?with_keywords={TMDB_ANIME_KEYWORD}&sort_by={}&include_adult=false{floor}&page={page}",
|
||||||
|
mode.discover_sort(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
match mode {
|
||||||
|
DiscoveryMode::Popular => format!("{seg}/popular?page={page}"),
|
||||||
|
DiscoveryMode::TopRated => format!("{seg}/top_rated?page={page}"),
|
||||||
|
DiscoveryMode::Trending => format!("trending/{seg}/week?page={page}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one metadata-only seed record from a discovery hit. `record_id` is a
|
||||||
|
/// synthetic, deterministic TMDB handle (`tmdb:movie:603`). Every discovery
|
||||||
|
/// marker the query carried is echoed so the seed survives `record_matches`.
|
||||||
|
fn build_seed_record(
|
||||||
|
hit: &TmdbHit,
|
||||||
|
kind: TmdbKind,
|
||||||
|
mode: DiscoveryMode,
|
||||||
|
anime: bool,
|
||||||
|
) -> DiscoveryRecord {
|
||||||
|
let (content_kind, seg) = match kind {
|
||||||
|
TmdbKind::Movie => ("movie", "movie"),
|
||||||
|
TmdbKind::Tv => ("episode", "tv"),
|
||||||
|
};
|
||||||
|
let mut fields = BTreeMap::new();
|
||||||
|
// Load-bearing marker stamps (see module doc): the query's filters were the
|
||||||
|
// mode marker, contentKind, and optionally anime.
|
||||||
|
fields.insert(mode.marker().to_string(), "true".to_string());
|
||||||
|
fields.insert("contentKind".to_string(), content_kind.to_string());
|
||||||
|
fields.insert("fileType".to_string(), "video".to_string());
|
||||||
|
if anime {
|
||||||
|
fields.insert("anime".to_string(), "true".to_string());
|
||||||
|
}
|
||||||
|
// Data the consumer reads to seed its per-title search.
|
||||||
|
fields.insert("title".to_string(), hit.title.clone());
|
||||||
|
fields.insert("tmdbid".to_string(), hit.tmdbid.to_string());
|
||||||
|
if let Some(y) = hit.year {
|
||||||
|
fields.insert("movieYear".to_string(), y.to_string());
|
||||||
|
}
|
||||||
|
DiscoveryRecord {
|
||||||
|
upstream_id: "prowlarr".to_string(),
|
||||||
|
record_id: format!("tmdb:{seg}:{}", hit.tmdbid),
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a discovery query to up to `max_results` catalog seed records,
|
||||||
|
/// walking TMDB pages (20/page) until the cap is reached or a page is empty.
|
||||||
|
/// Each page is a separate budget-gated TMDB call; `[]` on no mode/contentKind,
|
||||||
|
/// and an early break on the first rate-limit/miss (returning what we have).
|
||||||
|
pub(crate) async fn discover_records(
|
||||||
|
client: &TmdbClient,
|
||||||
|
budget: Option<&Arc<TmdbBudget>>,
|
||||||
|
query: &GatewayQuery,
|
||||||
|
max_results: usize,
|
||||||
|
) -> Vec<DiscoveryRecord> {
|
||||||
|
let (Some(mode), Some(kind)) = (discovery_mode(query), discovery_kind(query)) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let anime = filter_is_true(query, "anime");
|
||||||
|
// TMDB pages at TMDB_PAGE_SIZE; walk as many pages as needed to reach
|
||||||
|
// `max_results` seeds (meta-watch asks for ~30, i.e. 2 pages). Each page is a
|
||||||
|
// separate, budget-gated TMDB call.
|
||||||
|
let pages = max_results.div_ceil(TMDB_PAGE_SIZE).max(1) as u32;
|
||||||
|
let mut out: Vec<DiscoveryRecord> = Vec::new();
|
||||||
|
for page in 1..=pages {
|
||||||
|
if out.len() >= max_results {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(budget) = budget {
|
||||||
|
if matches!(
|
||||||
|
budget
|
||||||
|
.acquire(Duration::from_secs(TMDB_ENRICH_WAIT_DEADLINE_SECS))
|
||||||
|
.await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path_and_query = build_path_and_query(mode, kind, anime, page);
|
||||||
|
match client.discovery_list(kind, &path_and_query).await {
|
||||||
|
TmdbCall::Hit(hits) => {
|
||||||
|
if hits.is_empty() {
|
||||||
|
break; // ran past the last catalog page
|
||||||
|
}
|
||||||
|
out.extend(hits.iter().map(|h| build_seed_record(h, kind, mode, anime)));
|
||||||
|
}
|
||||||
|
TmdbCall::RateLimited(retry) => {
|
||||||
|
if let Some(budget) = budget {
|
||||||
|
budget.note_429(retry);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
TmdbCall::Miss => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.truncate(max_results);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming variant: resolve the catalog page and push each seed as a `Base`
|
||||||
|
/// event followed by `Done`. (A discovery list is a single HTTP call, so
|
||||||
|
/// there's no incremental benefit — this just mirrors the streaming contract.)
|
||||||
|
pub(crate) async fn discover_stream(
|
||||||
|
client: Arc<TmdbClient>,
|
||||||
|
budget: Option<Arc<TmdbBudget>>,
|
||||||
|
query: GatewayQuery,
|
||||||
|
max_results: usize,
|
||||||
|
mut tx: Sender<GatewaySearchEvent>,
|
||||||
|
) {
|
||||||
|
let records = discover_records(&client, budget.as_ref(), &query, max_results).await;
|
||||||
|
for record in records {
|
||||||
|
if tx.send(GatewaySearchEvent::Base(record)).await.is_err() {
|
||||||
|
return; // consumer gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx.send(GatewaySearchEvent::Done).await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,970 @@
|
|||||||
|
//! TMDB enrichment + poster store.
|
||||||
|
//!
|
||||||
|
//! Split out of the monolithic `torznab.rs` (pure file move; no behaviour change).
|
||||||
|
|
||||||
|
use crate::tmdb_budget::Lease;
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures::future::{BoxFuture, Shared};
|
||||||
|
use futures::FutureExt;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::title::clean_torrent_title;
|
||||||
|
use crate::tmdb::{
|
||||||
|
principal_top_n, season_episode_bounds, SeasonEpisodeBounds, TmdbCall, TmdbClient,
|
||||||
|
TmdbExternalIds, TmdbHit, TmdbKind, TmdbTvDetails,
|
||||||
|
};
|
||||||
|
use crate::tmdb_budget::TmdbBudget;
|
||||||
|
use meta_feeder_sdk::cache::MidhashCache;
|
||||||
|
use meta_feeder_sdk::query::GatewaySearchEvent;
|
||||||
|
use meta_feeder_sdk::types::DiscoveryRecord;
|
||||||
|
|
||||||
|
/// Resolve a record's transient `posterPath` into a `poster_url` (the public
|
||||||
|
/// TMDB poster CDN URL) in place, removing `posterPath`. In the feeder model the
|
||||||
|
/// **gateway core** seeds `poster_url` into a content-addressed `poster` cid
|
||||||
|
/// (same path as giphy/wikicommons previews) — the feeder never fetches or
|
||||||
|
/// stores the poster bytes, so there is no meta-core poster record and no
|
||||||
|
/// blockstore seed here. No-op when there's no `posterPath`.
|
||||||
|
pub(crate) fn set_poster_url(record: &mut DiscoveryRecord, tmdb: &TmdbClient) {
|
||||||
|
if let Some(path) = record.fields.remove("posterPath") {
|
||||||
|
record
|
||||||
|
.fields
|
||||||
|
.insert("poster_url".to_string(), tmdb.poster_cdn_url(&path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Long-lived TMDB + poster + subtitle-source state, split out of
|
||||||
|
/// [`TorznabPlugin`]. Owns the enrichment config (TMDB client/budget/inflight,
|
||||||
|
/// meta-core sink, bitswap blockstore, OpenSubtitles client + language set) and
|
||||||
|
/// a clone of the shared cache/HTTP client. Builds the per-call [`TmdbEnricher`]
|
||||||
|
/// and runs the search-time poster store. The plugin composes one of these and
|
||||||
|
/// delegates its enrich methods here.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct Enricher {
|
||||||
|
pub(crate) cache: Option<MidhashCache>,
|
||||||
|
pub(crate) tmdb: Option<Arc<TmdbClient>>,
|
||||||
|
/// Process-global TMDB token budget. Self-initialised in [`Enricher::new`]
|
||||||
|
/// (the feeder owns it now — the gateway's `set_tmdb_budget` injection is
|
||||||
|
/// gone). `Option` is kept so `tmdb_enricher` / the call sites stay
|
||||||
|
/// unchanged, but it is always `Some` in practice.
|
||||||
|
pub(crate) tmdb_budget: Option<Arc<TmdbBudget>>,
|
||||||
|
pub(crate) tmdb_inflight:
|
||||||
|
Arc<Mutex<HashMap<String, Shared<BoxFuture<'static, Option<TmdbHit>>>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Enricher {
|
||||||
|
/// Construct the enricher; everything else is filled in by the setters /
|
||||||
|
/// `configure`. The TMDB budget self-inits here (feeder-owned).
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cache: None,
|
||||||
|
tmdb: None,
|
||||||
|
tmdb_budget: Some(TmdbBudget::new(
|
||||||
|
crate::tmdb_budget::DEFAULT_TMDB_REFILL_PER_SEC,
|
||||||
|
crate::tmdb_budget::DEFAULT_TMDB_BURST,
|
||||||
|
)),
|
||||||
|
tmdb_inflight: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable TMDB enrichment with the given v4 bearer token. Empty /
|
||||||
|
/// whitespace-only tokens leave it disabled (no-op). Idempotent.
|
||||||
|
pub(crate) fn set_tmdb_token(&mut self, token: impl Into<String>) {
|
||||||
|
let token = token.into();
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
self.tmdb = None;
|
||||||
|
} else {
|
||||||
|
self.tmdb = Some(Arc::new(TmdbClient::new(token)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only: inject a [`TmdbClient`] with mock base URLs.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn set_tmdb_client_for_test(&mut self, client: TmdbClient) {
|
||||||
|
self.tmdb = Some(Arc::new(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a per-call [`TmdbEnricher`] from the long-lived state. `None` when
|
||||||
|
/// TMDB or the cache isn't configured (enrichment disabled). Cheap — every
|
||||||
|
/// field cloned is an `Arc`/clone-cheap handle, and the single-flight map is
|
||||||
|
/// shared (not copied) so coalescing spans calls.
|
||||||
|
pub(crate) fn tmdb_enricher(&self) -> Option<TmdbEnricher> {
|
||||||
|
Some(TmdbEnricher {
|
||||||
|
client: self.tmdb.clone()?,
|
||||||
|
cache: self.cache.clone()?,
|
||||||
|
budget: self.tmdb_budget.clone(),
|
||||||
|
inflight: self.tmdb_inflight.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundles the shared state a TMDB enrichment pass needs: the client, the
|
||||||
|
/// persistent redb cache, the optional process-global budget, and the
|
||||||
|
/// single-flight map. Cheap to clone (everything inside is `Arc`/clone-cheap).
|
||||||
|
/// Built per `handle_query` call by [`Enricher::tmdb_enricher`]; the
|
||||||
|
/// single-flight map is shared, not copied, so concurrent identical lookups
|
||||||
|
/// across many calls coalesce into one upstream request.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct TmdbEnricher {
|
||||||
|
pub(crate) client: Arc<TmdbClient>,
|
||||||
|
pub(crate) cache: MidhashCache,
|
||||||
|
pub(crate) budget: Option<Arc<TmdbBudget>>,
|
||||||
|
pub(crate) inflight: Arc<Mutex<HashMap<String, Shared<BoxFuture<'static, Option<TmdbHit>>>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmdbEnricher {
|
||||||
|
/// Cache → single-flight → budget-gated TMDB title search. Persistent
|
||||||
|
/// cache hits and single-flight followers consume **no** budget token;
|
||||||
|
/// only the leader future acquires one.
|
||||||
|
pub(crate) async fn search_cached(
|
||||||
|
&self,
|
||||||
|
kind: TmdbKind,
|
||||||
|
title: &str,
|
||||||
|
year: Option<u16>,
|
||||||
|
) -> Option<TmdbHit> {
|
||||||
|
let key = tmdb_search_key(kind, title, year);
|
||||||
|
// 1. Persistent cache (positive hit JSON or the `"null"` negative
|
||||||
|
// sentinel — both terminate without touching the budget).
|
||||||
|
if let Ok(Some(json)) = self.cache.get_tmdb_search(&key) {
|
||||||
|
return decode_cached_hit(&json);
|
||||||
|
}
|
||||||
|
// 2. Single-flight: the leader does the work; followers clone and
|
||||||
|
// await the same shared future.
|
||||||
|
let shared = {
|
||||||
|
let mut map = self.inflight.lock().expect("tmdb inflight poisoned");
|
||||||
|
if let Some(existing) = map.get(&key) {
|
||||||
|
existing.clone()
|
||||||
|
} else {
|
||||||
|
let fut = self
|
||||||
|
.clone()
|
||||||
|
.search_leader(kind, title.to_string(), year, key.clone());
|
||||||
|
let shared = fut.boxed().shared();
|
||||||
|
map.insert(key.clone(), shared.clone());
|
||||||
|
shared
|
||||||
|
}
|
||||||
|
};
|
||||||
|
shared.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single-flight leader: gate on the budget, call TMDB, persist the
|
||||||
|
/// result, and de-register from the in-flight map. Owns a cloned
|
||||||
|
/// [`TmdbEnricher`] so the future is `'static` for `Shared`.
|
||||||
|
pub(crate) async fn search_leader(
|
||||||
|
self,
|
||||||
|
kind: TmdbKind,
|
||||||
|
title: String,
|
||||||
|
year: Option<u16>,
|
||||||
|
key: String,
|
||||||
|
) -> Option<TmdbHit> {
|
||||||
|
if let Some(budget) = &self.budget {
|
||||||
|
if matches!(
|
||||||
|
budget
|
||||||
|
.acquire(Duration::from_secs(TMDB_ENRICH_WAIT_DEADLINE_SECS))
|
||||||
|
.await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
) {
|
||||||
|
// Couldn't get a token in time — transient, so don't poison
|
||||||
|
// the cache. De-register and surface a miss for this pass.
|
||||||
|
self.inflight
|
||||||
|
.lock()
|
||||||
|
.expect("tmdb inflight poisoned")
|
||||||
|
.remove(&key);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (result, cache_write) = match self.client.search(kind, &title, year).await {
|
||||||
|
TmdbCall::Hit(h) => {
|
||||||
|
let json = serde_json::to_string(&h).ok();
|
||||||
|
(Some(h), json)
|
||||||
|
}
|
||||||
|
// Confirmed miss: negative-cache it so the same title doesn't
|
||||||
|
// re-burn a budget token on the next search.
|
||||||
|
TmdbCall::Miss => (None, Some("null".to_string())),
|
||||||
|
// Rate-limited: pause the whole budget, but DON'T cache (the
|
||||||
|
// miss is transient — a later search should retry).
|
||||||
|
TmdbCall::RateLimited(retry) => {
|
||||||
|
if let Some(budget) = &self.budget {
|
||||||
|
budget.note_429(retry);
|
||||||
|
}
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(json) = cache_write {
|
||||||
|
let _ = self.cache.put_tmdb_search(&key, &json);
|
||||||
|
}
|
||||||
|
self.inflight
|
||||||
|
.lock()
|
||||||
|
.expect("tmdb inflight poisoned")
|
||||||
|
.remove(&key);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Budget-gate a single TMDB call, then resolve it: acquire a token
|
||||||
|
/// (returning `None` on deadline), map `Hit(x)` → `Some(on_hit(x))`,
|
||||||
|
/// `Miss` → `None`, and `RateLimited` → note the 429 and `None`. Each
|
||||||
|
/// caller keeps its own persistent-cache fast path (the decode / self-heal
|
||||||
|
/// rules differ per table) and does its cache write inside `on_hit`. This
|
||||||
|
/// centralises the budget-acquire + 429 handling the by-id detail lookups
|
||||||
|
/// all repeat. The single-flight title search ([`search_leader`]) keeps its
|
||||||
|
/// own copy — it also negative-caches and de-registers from the in-flight
|
||||||
|
/// map, which doesn't fit this shape.
|
||||||
|
async fn budgeted_call<T, R>(
|
||||||
|
&self,
|
||||||
|
call: impl std::future::Future<Output = TmdbCall<T>>,
|
||||||
|
on_hit: impl FnOnce(T) -> R,
|
||||||
|
) -> Option<R> {
|
||||||
|
if let Some(budget) = &self.budget {
|
||||||
|
if matches!(
|
||||||
|
budget
|
||||||
|
.acquire(Duration::from_secs(TMDB_ENRICH_WAIT_DEADLINE_SECS))
|
||||||
|
.await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match call.await {
|
||||||
|
TmdbCall::Hit(x) => Some(on_hit(x)),
|
||||||
|
TmdbCall::Miss => None,
|
||||||
|
TmdbCall::RateLimited(retry) => {
|
||||||
|
if let Some(budget) = &self.budget {
|
||||||
|
budget.note_429(retry);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache + budget-gated TMDB TV-details lookup (no single-flight — the
|
||||||
|
/// season-bearing-episode path is lower volume and the redb cache catches
|
||||||
|
/// most repeats). Cache hits consume no budget token.
|
||||||
|
pub(crate) async fn tv_details_cached(&self, tmdbid: u64) -> Option<TmdbTvDetails> {
|
||||||
|
let key = tmdbid.to_string();
|
||||||
|
if let Ok(Some(json)) = self.cache.get_tmdb_tvdetails(&key) {
|
||||||
|
if let Ok(details) = serde_json::from_str::<TmdbTvDetails>(&json) {
|
||||||
|
// Self-heal: an entry written before the display fields existed
|
||||||
|
// (or by a bounds-only fetch) lacks `name`/`overview`/poster —
|
||||||
|
// fall through to a fresh fetch so the anchored enrich path can
|
||||||
|
// build a hit. Structurally-complete entries return as-is.
|
||||||
|
if details.has_display() {
|
||||||
|
return Some(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.budgeted_call(self.client.tv_details(tmdbid), |details| {
|
||||||
|
if let Ok(json) = serde_json::to_string(&details) {
|
||||||
|
let _ = self.cache.put_tmdb_tvdetails(&key, &json);
|
||||||
|
}
|
||||||
|
details
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache + budget-gated TMDB `external_ids` lookup (`tvdb_id` / `imdb_id`
|
||||||
|
/// for a known tmdbid). External ids are immutable, so a hit is cached
|
||||||
|
/// forever. Cache hits consume no budget token.
|
||||||
|
pub(crate) async fn external_ids_cached(
|
||||||
|
&self,
|
||||||
|
kind: TmdbKind,
|
||||||
|
tmdbid: u64,
|
||||||
|
) -> Option<TmdbExternalIds> {
|
||||||
|
let key = tmdbid.to_string();
|
||||||
|
if let Ok(Some(json)) = self.cache.get_tmdb_extids(&key) {
|
||||||
|
return serde_json::from_str(&json).ok();
|
||||||
|
}
|
||||||
|
self.budgeted_call(self.client.external_ids(kind, tmdbid), |ids| {
|
||||||
|
if let Ok(json) = serde_json::to_string(&ids) {
|
||||||
|
let _ = self.cache.put_tmdb_extids(&key, &json);
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache + budget-gated TMDB `GET /3/movie/{id}` → [`TmdbHit`] (resolved by
|
||||||
|
/// id). The anchored movie enrichment source. Cache hits consume no budget.
|
||||||
|
pub(crate) async fn movie_hit_by_id_cached(&self, tmdbid: u64) -> Option<TmdbHit> {
|
||||||
|
let key = tmdbid.to_string();
|
||||||
|
if let Ok(Some(json)) = self.cache.get_tmdb_moviedetails(&key) {
|
||||||
|
return decode_cached_hit(&json);
|
||||||
|
}
|
||||||
|
self.budgeted_call(self.client.movie_details(tmdbid), |details| {
|
||||||
|
let hit = details.into_hit();
|
||||||
|
if let Ok(json) = serde_json::to_string(&hit) {
|
||||||
|
let _ = self.cache.put_tmdb_moviedetails(&key, &json);
|
||||||
|
}
|
||||||
|
hit
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a [`TmdbHit`] **by id** for an anchored record (known tmdbid),
|
||||||
|
/// bypassing the fuzzy title search entirely. TV hits are derived from the
|
||||||
|
/// cached TV-details payload (which doubles as the season-bounds source);
|
||||||
|
/// movie hits from `GET /3/movie/{id}`. `None` when the lookup fails — the
|
||||||
|
/// caller keeps the authoritative tmdbid and ships without the rest.
|
||||||
|
pub(crate) async fn hit_by_id_cached(&self, kind: TmdbKind, tmdbid: u64) -> Option<TmdbHit> {
|
||||||
|
match kind {
|
||||||
|
TmdbKind::Tv => self.tv_details_cached(tmdbid).await?.as_hit(tmdbid),
|
||||||
|
TmdbKind::Movie => self.movie_hit_by_id_cached(tmdbid).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache + budget-gated principal `search/multi`: resolve a bare keyword to
|
||||||
|
/// up to `n` confident `(tmdbid, kind)` anchors, most-popular first (or `[]`
|
||||||
|
/// → caller keeps today's `q=` path). The full ranked list (up to
|
||||||
|
/// [`CACHED_PRINCIPAL_DEPTH`]) is persisted and sliced to `n` on read, so a
|
||||||
|
/// vague keyword doesn't re-burn a budget token every search and changing
|
||||||
|
/// the top-N knob never needs a cache wipe. An empty list is negative-cached
|
||||||
|
/// (the `"[]"` sentinel).
|
||||||
|
pub(crate) async fn principal_top_n_cached(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
n: usize,
|
||||||
|
) -> Vec<(u64, TmdbKind)> {
|
||||||
|
let key = query.trim().to_lowercase();
|
||||||
|
if key.is_empty() || n == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if let Ok(Some(json)) = self.cache.get_tmdb_principal_topn(&key) {
|
||||||
|
let mut list = decode_principal_list(&json);
|
||||||
|
list.truncate(n);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
if let Some(budget) = &self.budget {
|
||||||
|
if matches!(
|
||||||
|
budget
|
||||||
|
.acquire(Duration::from_secs(TMDB_ENRICH_WAIT_DEADLINE_SECS))
|
||||||
|
.await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
) {
|
||||||
|
return Vec::new(); // transient — don't poison the cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ranked: Vec<(u64, TmdbKind)> = match self.client.search_multi(query).await {
|
||||||
|
TmdbCall::Hit(hits) => principal_top_n(&hits, query, CACHED_PRINCIPAL_DEPTH)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|h| h.kind().map(|k| (h.id, k)))
|
||||||
|
.collect(),
|
||||||
|
// The client folds a transient error (timeout / non-2xx) and a
|
||||||
|
// genuine "no results" into the same `Miss`, so we can't tell them
|
||||||
|
// apart here. Do NOT negative-cache it: a transient TMDB hiccup must
|
||||||
|
// not disable multi-anchor for this keyword until the next cache
|
||||||
|
// wipe (the bug that made a once-failed "black" search permanently
|
||||||
|
// anchor-less). Retry on the next search instead.
|
||||||
|
TmdbCall::Miss => return Vec::new(),
|
||||||
|
TmdbCall::RateLimited(retry) => {
|
||||||
|
if let Some(budget) = &self.budget {
|
||||||
|
budget.note_429(retry);
|
||||||
|
}
|
||||||
|
return Vec::new(); // don't cache a rate-limit
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Cache the resolved list — including an empty one, which here means
|
||||||
|
// "TMDB responded but nothing passed the relevance gate" (a genuine
|
||||||
|
// negative, safe to remember). Transient misses returned above, uncached.
|
||||||
|
let _ = self
|
||||||
|
.cache
|
||||||
|
.put_tmdb_principal_topn(&key, &encode_principal_list(&ranked));
|
||||||
|
let mut out = ranked;
|
||||||
|
out.truncate(n);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON shape persisted in the principal-search caches.
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct PrincipalEntry {
|
||||||
|
tmdbid: u64,
|
||||||
|
kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_str(kind: TmdbKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
TmdbKind::Movie => "movie",
|
||||||
|
TmdbKind::Tv => "tv",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_from_str(s: &str) -> Option<TmdbKind> {
|
||||||
|
match s {
|
||||||
|
"movie" => Some(TmdbKind::Movie),
|
||||||
|
"tv" => Some(TmdbKind::Tv),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a ranked anchor list (most-popular first) as a JSON array; `"[]"`
|
||||||
|
/// for empty (the negative-cache sentinel).
|
||||||
|
fn encode_principal_list(list: &[(u64, TmdbKind)]) -> String {
|
||||||
|
let entries: Vec<PrincipalEntry> = list
|
||||||
|
.iter()
|
||||||
|
.map(|(id, kind)| PrincipalEntry {
|
||||||
|
tmdbid: *id,
|
||||||
|
kind: kind_str(*kind).to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a ranked anchor list; unknown kinds are dropped, malformed JSON → [].
|
||||||
|
fn decode_principal_list(json: &str) -> Vec<(u64, TmdbKind)> {
|
||||||
|
serde_json::from_str::<Vec<PrincipalEntry>>(json)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| kind_from_str(&e.kind).map(|k| (e.tmdbid, k)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable lookup key for the TMDB-search cache + single-flight map:
|
||||||
|
/// `"<m|t>\x01<cleaned-title>\x01<year-or-empty>"`.
|
||||||
|
pub(crate) fn tmdb_search_key(kind: TmdbKind, title: &str, year: Option<u16>) -> String {
|
||||||
|
let k = match kind {
|
||||||
|
TmdbKind::Movie => 'm',
|
||||||
|
TmdbKind::Tv => 't',
|
||||||
|
};
|
||||||
|
let y = year.map(|y| y.to_string()).unwrap_or_default();
|
||||||
|
format!("{k}\u{1}{title}\u{1}{y}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a cached TMDB-search value: the `"null"` sentinel → confirmed miss
|
||||||
|
/// (`None`), anything else → the encoded [`TmdbHit`].
|
||||||
|
pub(crate) fn decode_cached_hit(json: &str) -> Option<TmdbHit> {
|
||||||
|
if json == "null" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
serde_json::from_str(json).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Field-level enrichment delta, or a verdict to drop/skip the record.
|
||||||
|
/// Computed without mutating the base record so the streaming path can emit
|
||||||
|
/// it as a [`GatewaySearchEvent::EnrichPatch`] / [`GatewaySearchEvent::Drop`].
|
||||||
|
pub(crate) enum EnrichOutcome {
|
||||||
|
/// Apply `set` (insert/overwrite) and `remove` (delete) to the base record.
|
||||||
|
Patch {
|
||||||
|
set: BTreeMap<String, String>,
|
||||||
|
remove: Vec<String>,
|
||||||
|
},
|
||||||
|
/// A TV match whose title-parsed season/episode is positively out of
|
||||||
|
/// TMDB's bounds — retract the record entirely (mirrors the buffered
|
||||||
|
/// path's `retain` drop).
|
||||||
|
Drop,
|
||||||
|
/// Not a TV/movie record, no usable title, or no TMDB match — leave the
|
||||||
|
/// base record untouched, emit no patch.
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort TMDB enrichment for a single record, computed as a delta.
|
||||||
|
/// No-op if the record's contentKind isn't a known TV/movie kind, if title
|
||||||
|
/// cleaning fails to produce a non-empty query, or if TMDB returns no hits.
|
||||||
|
/// Routes every TMDB call through `enricher` (cache + single-flight + budget).
|
||||||
|
///
|
||||||
|
/// When the real enrichment is a [`EnrichOutcome::Noop`] and the test-only
|
||||||
|
/// [`crate::consts::ENV_STUB_UNMATCHED`] flag is set, this falls back to
|
||||||
|
/// [`stub_unmatched_patch`] — fabricated metadata so an unmatched record
|
||||||
|
/// (e.g. an adult release tagged `categories/XXX` that never matches TMDB)
|
||||||
|
/// still surfaces in meta-watch for filter validation. Off by default.
|
||||||
|
pub(crate) async fn compute_enrichment(
|
||||||
|
record: &DiscoveryRecord,
|
||||||
|
enricher: &TmdbEnricher,
|
||||||
|
) -> EnrichOutcome {
|
||||||
|
match compute_enrichment_inner(record, enricher).await {
|
||||||
|
EnrichOutcome::Noop => stub_unmatched_patch(record).unwrap_or(EnrichOutcome::Noop),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the test-only unmatched-stub flag enabled? Read once. Exposed to
|
||||||
|
/// `xml.rs` so `into_discovery_record` can stamp a base-record `fileType`
|
||||||
|
/// for kind-less (e.g. `categories/XXX`) records — without it the
|
||||||
|
/// `fileType:video` routing/base filter drops them before this enrichment
|
||||||
|
/// stub runs (the stub only adds poster/description; see `build_stub_patch`).
|
||||||
|
pub(crate) fn stub_unmatched_enabled() -> bool {
|
||||||
|
static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*FLAG.get_or_init(|| {
|
||||||
|
std::env::var(crate::consts::ENV_STUB_UNMATCHED)
|
||||||
|
.map(|v| {
|
||||||
|
matches!(
|
||||||
|
v.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"1" | "true" | "yes" | "on"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Test-only.** Fabricate the minimum metadata an unmatched record needs to
|
||||||
|
/// pass meta-watch's quality gate (`is_video && has_poster && has_description`,
|
||||||
|
/// see `meta-watch/src/gate.rs`), so it surfaces in the UI for adult-filter
|
||||||
|
/// validation. Returns `None` unless [`stub_unmatched_enabled`] and the record
|
||||||
|
/// has a title. Only fills what's missing — a `5xxx` anime miss keeps its
|
||||||
|
/// `episode` kind and just gains a poster/description; an `XXX` (`6xxx`) record
|
||||||
|
/// with no kind at all gains video kind too. The `poster` is a non-resolvable
|
||||||
|
/// marker (the tile renders broken — card visibility is what the test toggles),
|
||||||
|
/// and the canonical `categories/XXX` tag is already on the record from
|
||||||
|
/// `xml.rs`, so the consumer-side filter keys on it unchanged.
|
||||||
|
fn stub_unmatched_patch(record: &DiscoveryRecord) -> Option<EnrichOutcome> {
|
||||||
|
if !stub_unmatched_enabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
build_stub_patch(&record.fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure stub-patch builder (env-independent, for unit tests). Fills only the
|
||||||
|
/// gate fields that are missing. Returns `None` if there's no title (can't
|
||||||
|
/// render a card) or nothing needs fabricating (already passes the gate).
|
||||||
|
fn build_stub_patch(f: &BTreeMap<String, String>) -> Option<EnrichOutcome> {
|
||||||
|
if !f
|
||||||
|
.get("title")
|
||||||
|
.map(|t| !t.trim().is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let is_video = f.get("fileType").map(|v| v.eq_ignore_ascii_case("video")) == Some(true)
|
||||||
|
|| matches!(
|
||||||
|
f.get("videoType")
|
||||||
|
.map(|v| v.to_ascii_lowercase())
|
||||||
|
.as_deref(),
|
||||||
|
Some("movie") | Some("tvshow") | Some("episode")
|
||||||
|
);
|
||||||
|
let has_poster = f
|
||||||
|
.get("poster")
|
||||||
|
.map(|p| !p.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let has_desc = f
|
||||||
|
.get("description")
|
||||||
|
.map(|d| !d.trim().is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| f.get("plot/eng")
|
||||||
|
.map(|d| !d.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let mut set: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
if !is_video {
|
||||||
|
set.insert("fileType".to_string(), "video".to_string());
|
||||||
|
set.insert("videoType".to_string(), "movie".to_string());
|
||||||
|
set.insert("contentKind".to_string(), "movie".to_string());
|
||||||
|
}
|
||||||
|
if !has_poster {
|
||||||
|
// Non-resolvable marker — the image 404s (broken tile), but the card
|
||||||
|
// renders, which is all the on/off filter test needs.
|
||||||
|
set.insert("poster".to_string(), "stub-no-tmdb-poster".to_string());
|
||||||
|
}
|
||||||
|
if !has_desc {
|
||||||
|
set.insert(
|
||||||
|
"description".to_string(),
|
||||||
|
"[STUB] Simulated unmatched/polluted record (no TMDB match) — for adult-filter testing.".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if set.is_empty() {
|
||||||
|
// Already passes the gate on its own — nothing to fabricate.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(EnrichOutcome::Patch {
|
||||||
|
set,
|
||||||
|
remove: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod stub_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fields(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_set(outcome: Option<EnrichOutcome>) -> BTreeMap<String, String> {
|
||||||
|
match outcome {
|
||||||
|
Some(EnrichOutcome::Patch { set, .. }) => set,
|
||||||
|
_ => panic!("expected a stub patch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xxx_record_with_no_kind_gets_full_video_stub() {
|
||||||
|
// A pure-XXX record (no contentKind/fileType, just a title + category)
|
||||||
|
// gets video kind + poster + description so it passes meta-watch.
|
||||||
|
let set = patch_set(build_stub_patch(&fields(&[
|
||||||
|
("title", "Some Release"),
|
||||||
|
("categories/XXX", "true"),
|
||||||
|
])));
|
||||||
|
assert_eq!(set.get("fileType").map(String::as_str), Some("video"));
|
||||||
|
assert_eq!(set.get("videoType").map(String::as_str), Some("movie"));
|
||||||
|
assert!(set.contains_key("poster"));
|
||||||
|
assert!(set.contains_key("description"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_video_kind_and_desc_are_preserved() {
|
||||||
|
// A 5xxx anime miss already carries episode kind; only the missing
|
||||||
|
// poster is fabricated — kind and description are left untouched.
|
||||||
|
let set = patch_set(build_stub_patch(&fields(&[
|
||||||
|
("title", "Anime S01E01"),
|
||||||
|
("fileType", "video"),
|
||||||
|
("videoType", "tvshow"),
|
||||||
|
("contentKind", "episode"),
|
||||||
|
("plot/eng", "real synopsis"),
|
||||||
|
])));
|
||||||
|
assert!(!set.contains_key("fileType"));
|
||||||
|
assert!(!set.contains_key("videoType"));
|
||||||
|
assert!(!set.contains_key("description"));
|
||||||
|
assert_eq!(
|
||||||
|
set.get("poster").map(String::as_str),
|
||||||
|
Some("stub-no-tmdb-poster")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_title_or_nothing_missing_returns_none() {
|
||||||
|
// No title ⇒ can't build a card.
|
||||||
|
assert!(build_stub_patch(&fields(&[("categories/XXX", "true")])).is_none());
|
||||||
|
// Already passes the gate ⇒ nothing to fabricate.
|
||||||
|
assert!(build_stub_patch(&fields(&[
|
||||||
|
("title", "x"),
|
||||||
|
("fileType", "video"),
|
||||||
|
("poster", "real-cid"),
|
||||||
|
("description", "real"),
|
||||||
|
]))
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort TMDB enrichment for a single record, computed as a delta.
|
||||||
|
/// No-op if the record's contentKind isn't a known TV/movie kind, if title
|
||||||
|
/// cleaning fails to produce a non-empty query, or if TMDB returns no hits.
|
||||||
|
/// Routes every TMDB call through `enricher` (cache + single-flight + budget).
|
||||||
|
///
|
||||||
|
/// Two stages: [`resolve_enrichment_hit`] picks the canonical TMDB entry (by
|
||||||
|
/// anchored id, else fuzzy title search), then [`build_enrichment_patch`]
|
||||||
|
/// turns that hit into the field delta (and runs the TV season/episode
|
||||||
|
/// bounds-check that can drop the record).
|
||||||
|
async fn compute_enrichment_inner(
|
||||||
|
record: &DiscoveryRecord,
|
||||||
|
enricher: &TmdbEnricher,
|
||||||
|
) -> EnrichOutcome {
|
||||||
|
// Only enrich TV/movie records.
|
||||||
|
let kind = match record.fields.get("contentKind").map(String::as_str) {
|
||||||
|
Some("movie") => TmdbKind::Movie,
|
||||||
|
Some("episode") => TmdbKind::Tv,
|
||||||
|
_ => return EnrichOutcome::Noop,
|
||||||
|
};
|
||||||
|
let raw_title = record
|
||||||
|
.fields
|
||||||
|
.get("title")
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
|
||||||
|
let (hit, matched_kind) = match resolve_enrichment_hit(record, enricher, kind, &raw_title).await
|
||||||
|
{
|
||||||
|
Some(resolved) => resolved,
|
||||||
|
None => return EnrichOutcome::Noop,
|
||||||
|
};
|
||||||
|
build_enrichment_patch(record, enricher, hit, matched_kind, raw_title).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the canonical TMDB entry for `record`. Prefers the **anchored** path —
|
||||||
|
/// an authoritative `tmdbid` stamped on the Base by the structural
|
||||||
|
/// tvsearch/movie fan-out or principal-search — resolving by id so a fuzzy
|
||||||
|
/// match can't overwrite the anchor. Falls back to a **fuzzy** title search
|
||||||
|
/// (with a movie↔tv kind fallback, since torznab categories mislabel kinds).
|
||||||
|
/// `None` ⇒ the caller should no-op.
|
||||||
|
async fn resolve_enrichment_hit(
|
||||||
|
record: &DiscoveryRecord,
|
||||||
|
enricher: &TmdbEnricher,
|
||||||
|
kind: TmdbKind,
|
||||||
|
raw_title: &Option<String>,
|
||||||
|
) -> Option<(TmdbHit, TmdbKind)> {
|
||||||
|
let anchored_id = record
|
||||||
|
.fields
|
||||||
|
.get("tmdbid")
|
||||||
|
.and_then(|s| s.trim().parse::<u64>().ok());
|
||||||
|
if let Some(id) = anchored_id {
|
||||||
|
// Anchored: resolve by id. On lookup failure we keep the stamped
|
||||||
|
// tmdbid and skip the rest (no-op).
|
||||||
|
return enricher.hit_by_id_cached(kind, id).await.map(|h| (h, kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_title = raw_title.as_ref()?;
|
||||||
|
let cleaned = clean_torrent_title(raw_title);
|
||||||
|
if cleaned.title.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Kind fallback: torznab/newznab categories mislabel films as episodes
|
||||||
|
// (e.g. "CODE: White" arrives as cat 5070) and vice-versa. If the
|
||||||
|
// primary endpoint misses, try the other before giving up.
|
||||||
|
let other = match kind {
|
||||||
|
TmdbKind::Tv => TmdbKind::Movie,
|
||||||
|
TmdbKind::Movie => TmdbKind::Tv,
|
||||||
|
};
|
||||||
|
if let Some(h) = enricher
|
||||||
|
.search_cached(kind, &cleaned.title, cleaned.year)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Some((h, kind));
|
||||||
|
}
|
||||||
|
enricher
|
||||||
|
.search_cached(other, &cleaned.title, cleaned.year)
|
||||||
|
.await
|
||||||
|
.map(|h| (h, other))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a resolved TMDB `hit` into the field delta: title/synopsis/year/tmdbid,
|
||||||
|
/// content-kind reconciliation (movie clears bogus season/episode; tv normalises
|
||||||
|
/// to `episode` and bounds-checks the parsed season/episode — an out-of-bounds
|
||||||
|
/// verdict drops the record), and the transient `posterPath` the poster store
|
||||||
|
/// consumes. `raw_title` is preserved as `originalTitle`.
|
||||||
|
async fn build_enrichment_patch(
|
||||||
|
record: &DiscoveryRecord,
|
||||||
|
enricher: &TmdbEnricher,
|
||||||
|
hit: TmdbHit,
|
||||||
|
matched_kind: TmdbKind,
|
||||||
|
raw_title: Option<String>,
|
||||||
|
) -> EnrichOutcome {
|
||||||
|
let mut set: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
let mut remove: Vec<String> = Vec::new();
|
||||||
|
set.insert("title".to_string(), hit.title.clone());
|
||||||
|
// Preserve the raw tracker release title (quality tags / release-group
|
||||||
|
// info) in `originalTitle`. This is the canonical home now that the
|
||||||
|
// bespoke `releaseTitle` key is retired — reuse-before-invent
|
||||||
|
// (METADATA_KEYS.md rule #1, §14.10 title sprawl).
|
||||||
|
if let Some(raw_title) = raw_title {
|
||||||
|
set.insert("originalTitle".to_string(), raw_title);
|
||||||
|
}
|
||||||
|
// TMDB's original-language title (e.g. "ナルト") is a genuine localized
|
||||||
|
// title — file it under the namespaced `titles/{lang3}` (METADATA_KEYS.md
|
||||||
|
// §3), keyed by TMDB's `original_language`, instead of clobbering
|
||||||
|
// `originalTitle`. Skipped when the language is outside the common 639-1
|
||||||
|
// set or the title is blank (originalTitle still carries the raw title).
|
||||||
|
if let Some(orig) = hit.original_title.clone() {
|
||||||
|
if !orig.trim().is_empty() {
|
||||||
|
if let Some(lang3) = hit.original_lang3() {
|
||||||
|
set.insert(format!("titles/{lang3}"), orig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(o) = hit.overview.clone() {
|
||||||
|
if !o.trim().is_empty() {
|
||||||
|
// Canonical synopsis field is the language-namespaced
|
||||||
|
// `description/{lang3}` (METADATA_KEYS.md §14.6) — the
|
||||||
|
// cross-content-kind synopsis, and the form meta-share's free-text
|
||||||
|
// tokenizer actually indexes (`description/*` prefix, §12). TMDB
|
||||||
|
// search responses default to en-US (the client sends no
|
||||||
|
// `language` param), so the overview is English prose → `eng`. The
|
||||||
|
// flat `overview`/`description` keys are neither in the registry
|
||||||
|
// nor indexed, so they'd be invisible to cohort search.
|
||||||
|
set.insert("description/eng".to_string(), o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(y) = hit.year {
|
||||||
|
set.insert("movieYear".to_string(), y.to_string());
|
||||||
|
}
|
||||||
|
set.insert("tmdbid".to_string(), hit.tmdbid.to_string());
|
||||||
|
// Reconcile content kind against what TMDB actually matched. A movie
|
||||||
|
// match clears the bogus season/episode; a TV match normalizes
|
||||||
|
// contentKind to "episode".
|
||||||
|
match matched_kind {
|
||||||
|
TmdbKind::Movie => {
|
||||||
|
set.insert("contentKind".to_string(), "movie".to_string());
|
||||||
|
// Co-write the narrower `videoType` facet (METADATA_KEYS.md §14.11
|
||||||
|
// — contentKind=movie ↔ videoType=movie). Overwrites any
|
||||||
|
// category-derived videoType from `into_discovery_record` with the
|
||||||
|
// TMDB-reconciled kind.
|
||||||
|
set.insert("videoType".to_string(), "movie".to_string());
|
||||||
|
set.insert("fileType".to_string(), "video".to_string());
|
||||||
|
remove.push("season".to_string());
|
||||||
|
remove.push("episode".to_string());
|
||||||
|
}
|
||||||
|
TmdbKind::Tv => {
|
||||||
|
set.insert("contentKind".to_string(), "episode".to_string());
|
||||||
|
// contentKind=episode ↔ videoType=tvshow (METADATA_KEYS.md §14.11).
|
||||||
|
set.insert("videoType".to_string(), "tvshow".to_string());
|
||||||
|
// Season/episode boundary validation. Only a parsed `season`
|
||||||
|
// triggers a details lookup, then [`season_episode_bounds`] grades
|
||||||
|
// the parse against TMDB's real structure:
|
||||||
|
// - SeasonOverflow (the `S2`-of-a-1-TMDB-season anime cour case):
|
||||||
|
// the TMDB match is sound, only the season number is a
|
||||||
|
// fansub-numbering artifact — keep the record but strip the
|
||||||
|
// misleading `season` so it doesn't claim a season TMDB lacks.
|
||||||
|
// - Contradiction (negative season, or episode past a matched
|
||||||
|
// season's count): the parse is wrong — drop the whole record
|
||||||
|
// so meta-core never ingests it.
|
||||||
|
let season = record
|
||||||
|
.fields
|
||||||
|
.get("season")
|
||||||
|
.and_then(|s| s.trim().parse::<i64>().ok());
|
||||||
|
if season.is_some() {
|
||||||
|
let episode = record
|
||||||
|
.fields
|
||||||
|
.get("episode")
|
||||||
|
.and_then(|e| e.trim().parse::<i64>().ok());
|
||||||
|
if let Some(details) = enricher.tv_details_cached(hit.tmdbid).await {
|
||||||
|
match season_episode_bounds(&details, season, episode) {
|
||||||
|
SeasonEpisodeBounds::Ok => {}
|
||||||
|
SeasonEpisodeBounds::SeasonOverflow => {
|
||||||
|
debug!(
|
||||||
|
target: "meta-share::gateway",
|
||||||
|
upstream = "prowlarr",
|
||||||
|
tmdbid = hit.tmdbid,
|
||||||
|
season = ?season,
|
||||||
|
episode = ?episode,
|
||||||
|
number_of_seasons = details.number_of_seasons,
|
||||||
|
record_id = %record.record_id,
|
||||||
|
"title-parsed season past TMDB bounds (cour-as-season); stripping season, keeping record"
|
||||||
|
);
|
||||||
|
remove.push("season".to_string());
|
||||||
|
}
|
||||||
|
SeasonEpisodeBounds::Contradiction => {
|
||||||
|
// The parsed season/episode contradicts TMDB's
|
||||||
|
// structure — e.g. an explicit "S01E25" on a show
|
||||||
|
// whose season 1 has 7 episodes. That is NOT absolute
|
||||||
|
// numbering (absolute-numbered releases carry no
|
||||||
|
// season token and are left season-less upstream, so
|
||||||
|
// they never reach here); it's a mismatched or
|
||||||
|
// garbled record, so drop it rather than surfacing a
|
||||||
|
// phantom episode. We deliberately no longer try to
|
||||||
|
// "rescue" it by remapping the episode onto a TMDB
|
||||||
|
// season — a season is only ever asserted from a
|
||||||
|
// trusted explicit token, never inferred.
|
||||||
|
warn!(
|
||||||
|
target: "meta-share::gateway",
|
||||||
|
upstream = "prowlarr",
|
||||||
|
tmdbid = hit.tmdbid,
|
||||||
|
season = ?season,
|
||||||
|
episode = ?episode,
|
||||||
|
number_of_seasons = details.number_of_seasons,
|
||||||
|
record_id = %record.record_id,
|
||||||
|
"title-parsed season/episode contradicts TMDB; dropping record"
|
||||||
|
);
|
||||||
|
return EnrichOutcome::Drop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(p) = hit.poster_path.clone() {
|
||||||
|
// Transient input only — `store_poster_inner` reads it to fetch the
|
||||||
|
// bytes, sets `poster=<cid>`, and removes it before the record ships.
|
||||||
|
let normalised = p.trim_start_matches('/').to_string();
|
||||||
|
set.insert("posterPath".to_string(), normalised);
|
||||||
|
}
|
||||||
|
EnrichOutcome::Patch { set, remove }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buffered-path enrichment: apply [`compute_enrichment`]'s delta to `record`
|
||||||
|
/// in place. Returns `false` when the record should be dropped (out-of-bounds
|
||||||
|
/// season/episode).
|
||||||
|
pub(crate) async fn enrich_record_with_tmdb(
|
||||||
|
record: &mut DiscoveryRecord,
|
||||||
|
enricher: &TmdbEnricher,
|
||||||
|
) -> bool {
|
||||||
|
match compute_enrichment(record, enricher).await {
|
||||||
|
EnrichOutcome::Drop => false,
|
||||||
|
EnrichOutcome::Noop => true,
|
||||||
|
EnrichOutcome::Patch { set, remove } => {
|
||||||
|
for k in &remove {
|
||||||
|
record.fields.remove(k);
|
||||||
|
}
|
||||||
|
for (k, v) in set {
|
||||||
|
record.fields.insert(k, v);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming-path enrichment for one base record: compute the TMDB delta,
|
||||||
|
/// store the poster (off-budget), write the enriched bibrec sidecar, and
|
||||||
|
/// return the [`GatewaySearchEvent`]s the consumer should apply on top of the
|
||||||
|
/// already-emitted `Base`. Owns its inputs so the enclosing stream is
|
||||||
|
/// `'static` (dropping the stream cancels in-flight TMDB work and frees the
|
||||||
|
/// budget). Mirrors the buffered `handle_query` enrich → poster → bibrec
|
||||||
|
/// phases, minus the cross-record `retain` (a drop is signalled per-record via
|
||||||
|
/// a `Drop` event instead).
|
||||||
|
pub(crate) async fn enrich_one_streaming(
|
||||||
|
record: DiscoveryRecord,
|
||||||
|
enricher: Option<TmdbEnricher>,
|
||||||
|
cache: Option<MidhashCache>,
|
||||||
|
) -> Vec<GatewaySearchEvent> {
|
||||||
|
let record_id = record.record_id.clone();
|
||||||
|
let mut working = record;
|
||||||
|
let mut events: Vec<GatewaySearchEvent> = Vec::new();
|
||||||
|
|
||||||
|
// 1. TMDB metadata enrichment (budget-gated inside the enricher).
|
||||||
|
if let Some(enricher) = &enricher {
|
||||||
|
match compute_enrichment(&working, enricher).await {
|
||||||
|
EnrichOutcome::Drop => {
|
||||||
|
// The Base already shipped; retract it. Don't poster-store or
|
||||||
|
// bibrec-cache the bogus record (matches the buffered retain).
|
||||||
|
events.push(GatewaySearchEvent::Drop { record_id });
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
EnrichOutcome::Noop => {}
|
||||||
|
EnrichOutcome::Patch { set, remove } => {
|
||||||
|
for k in &remove {
|
||||||
|
working.fields.remove(k);
|
||||||
|
}
|
||||||
|
for (k, v) in &set {
|
||||||
|
working.fields.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
// `posterPath` is an internal transient — `store_poster_inner`
|
||||||
|
// consumes it and emits `poster` instead. Never expose it.
|
||||||
|
let mut wire_set = set;
|
||||||
|
wire_set.remove("posterPath");
|
||||||
|
if !wire_set.is_empty() || !remove.is_empty() {
|
||||||
|
events.push(GatewaySearchEvent::EnrichPatch {
|
||||||
|
record_id: record_id.clone(),
|
||||||
|
set: wire_set,
|
||||||
|
remove,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Poster: resolve the TMDB CDN url and emit it as `poster_url` (off the
|
||||||
|
// TMDB API budget — different CDN host). The gateway core seeds it into a
|
||||||
|
// content-addressed `poster` cid; the feeder no longer fetches or stores
|
||||||
|
// the bytes. `poster_url` IS persisted into `working.fields` so it rides
|
||||||
|
// bibrec into `compute_outcomes`, where the core seeds it onto the stored
|
||||||
|
// video record too.
|
||||||
|
if let Some(enricher) = &enricher {
|
||||||
|
if working.fields.contains_key("posterPath") {
|
||||||
|
set_poster_url(&mut working, &enricher.client);
|
||||||
|
if let Some(url) = working.fields.get("poster_url").cloned() {
|
||||||
|
let mut set = BTreeMap::new();
|
||||||
|
set.insert("poster_url".to_string(), url);
|
||||||
|
events.push(GatewaySearchEvent::EnrichPatch {
|
||||||
|
record_id: record_id.clone(),
|
||||||
|
set,
|
||||||
|
remove: vec!["posterPath".to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. bibrec sidecar (enriched fields) — same as the buffered path.
|
||||||
|
if let Some(cache) = &cache {
|
||||||
|
if let Err(e) = cache.put_bibrec(&working.record_id, &working.fields) {
|
||||||
|
warn!(
|
||||||
|
target: "meta-share::gateway",
|
||||||
|
upstream = "prowlarr",
|
||||||
|
record_id = working.record_id,
|
||||||
|
error = %e,
|
||||||
|
"bibrec cache put failed (non-fatal; compute_outcomes will NotFound)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::consts::*;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! Title-tag parsing moved to the shared `meta_feeder_sdk::filename_meta`
|
||||||
|
//! (de-duplicated — was copied verbatim in tribler + torznab feeders). This
|
||||||
|
//! re-export keeps the existing `crate::filename_meta::extract_*` call sites
|
||||||
|
//! working unchanged. New code can use `meta_feeder_sdk::filename_meta` directly.
|
||||||
|
pub use meta_feeder_sdk::filename_meta::*;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! `tribler-feeder` library surface. The binary (`main.rs`) wraps the Tribler
|
||||||
|
//! plugin in [`meta_feeder_sdk::serve_feeders`].
|
||||||
|
//!
|
||||||
|
//! This crate was split out of `meta-feeder-torrent` (the unified torrent
|
||||||
|
//! feeder) so tribler is a **single-plugin** feeder — its dashboard config
|
||||||
|
//! panel resolves to the tribler schema instead of colliding with prowlarr on a
|
||||||
|
//! shared feeder's single `/config` surface.
|
||||||
|
//!
|
||||||
|
//! It carries a **copy** of the source-agnostic "torrent-core" TMDB
|
||||||
|
//! catalog-discovery stack (`tmdb`, `tmdb_budget`, `discovery`, `enrich`,
|
||||||
|
//! `title`, `consts`, `filename_meta`). That stack cannot live in a container
|
||||||
|
//! plugin — a container plugin enriches a known CID, it can't answer "what's
|
||||||
|
//! popular"; the browse/catalog primitive is inherently in-process and shared by
|
||||||
|
//! every torrent source. The librqbit BitTorrent full-file fetch (`bt.rs` in
|
||||||
|
//! meta-feeder-torrent) is **not** copied here: the tribler source enumerates a
|
||||||
|
//! torrent's files from the Tribler core's metainfo response (`tribler::metainfo`),
|
||||||
|
//! never via librqbit.
|
||||||
|
|
||||||
|
// The copied torrent-core modules carry the same clippy lints allowed in the
|
||||||
|
// meta-feeder-torrent origin, so this crate meets a `-D warnings` bar without
|
||||||
|
// diverging the copies from their twin. `dead_code` is allowed because the
|
||||||
|
// copied TMDB stack is source-agnostic: the tribler source drives catalog
|
||||||
|
// discovery + streaming enrichment, but not the indexer-result reconciliation
|
||||||
|
// helpers (norm_tokens / principal_top_n / episode_in_season / the TMDB
|
||||||
|
// multi-search DTOs) that only the torznab source in meta-feeder-torrent calls.
|
||||||
|
// Keeping them verbatim (rather than pruning) keeps the copies diff-clean
|
||||||
|
// against their twin.
|
||||||
|
#![allow(
|
||||||
|
dead_code,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::unnecessary_get_then_check,
|
||||||
|
clippy::needless_borrow,
|
||||||
|
clippy::needless_borrows_for_generic_args,
|
||||||
|
clippy::manual_find,
|
||||||
|
clippy::if_same_then_else,
|
||||||
|
clippy::items_after_test_module
|
||||||
|
)]
|
||||||
|
|
||||||
|
// Source-agnostic torrent pipeline (copied "torrent-core"): TMDB client +
|
||||||
|
// budget, keyword-less catalog discovery, release-title cleaning, and the
|
||||||
|
// in-Rust TMDB enrichment reconciliation used on the discovery/search stream.
|
||||||
|
pub mod consts;
|
||||||
|
pub mod discovery;
|
||||||
|
pub mod enrich;
|
||||||
|
pub mod filename_meta;
|
||||||
|
pub mod title;
|
||||||
|
pub mod tmdb;
|
||||||
|
pub mod tmdb_budget;
|
||||||
|
|
||||||
|
// The tribler source itself (IPv8-overlay client of a headless Tribler core).
|
||||||
|
pub mod tribler;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! `tribler-feeder` — single-source BitTorrent feeder sidecar for the
|
||||||
|
//! decentralized Tribler (IPv8 overlay) network.
|
||||||
|
//!
|
||||||
|
//! One plugin (`tribler`) served through [`meta_feeder_sdk::serve_feeders`]. A
|
||||||
|
//! thin client of a headless Tribler core (reached at `TRIBLER_SIDECAR_URL`); it
|
||||||
|
//! finds records over the Tribler swarm and enriches them via the copied
|
||||||
|
//! torrent-core TMDB catalog-discovery stack. Split out of `meta-feeder-torrent`
|
||||||
|
//! so its dashboard config panel resolves to the tribler schema.
|
||||||
|
//!
|
||||||
|
//! Env (shared): `META_FEEDER_HTTP_LISTEN` (default `0.0.0.0:8080`),
|
||||||
|
//! `META_FEEDER_STATE_DIR` (default `/data/meta-feeder`), `META_CORE_URL` (D4
|
||||||
|
//! self-publish), `RUST_LOG`.
|
||||||
|
//! Env (tribler source): `TRIBLER_SIDECAR_URL`, `TRIBLER_API_KEY`,
|
||||||
|
//! `TMDB_TOKEN`/`TMDB_LANGUAGE`, plus the enrichment-plugin URLs
|
||||||
|
//! (`FILENAME_PARSER_URL`, `TMDB_PLUGIN_URL`, `META_FEEDER_CALLBACK_URL`).
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use meta_feeder_sdk::plugin::FeederPlugin;
|
||||||
|
use meta_feeder_sdk::serve_feeders;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use tribler_feeder::tribler::{TriblerConfigFile, TriblerPlugin};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let listen: SocketAddr = std::env::var("META_FEEDER_HTTP_LISTEN")
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:8080".to_string())
|
||||||
|
.parse()?;
|
||||||
|
let state_dir =
|
||||||
|
std::env::var("META_FEEDER_STATE_DIR").unwrap_or_else(|_| "/data/meta-feeder".to_string());
|
||||||
|
|
||||||
|
// Env is the first-boot seed; the persisted config.json (under the
|
||||||
|
// per-source cache dir) wins once saved through the dashboard. The tribler
|
||||||
|
// source builds its enrichment driver in configure().
|
||||||
|
let mut tr = TriblerPlugin::new();
|
||||||
|
tr.set_seed_config(TriblerConfigFile::from_env());
|
||||||
|
|
||||||
|
let plugins: Vec<Box<dyn FeederPlugin>> = vec![Box::new(tr)];
|
||||||
|
|
||||||
|
info!(target: "meta-feeder", loaded = plugins.len(), "tribler feeder starting");
|
||||||
|
|
||||||
|
serve_feeders(plugins, state_dir, listen).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
//! Pure torrent-title-cleaning logic.
|
||||||
|
//!
|
||||||
|
//! Split out of the monolithic `torznab.rs` (pure file move; no behaviour change).
|
||||||
|
|
||||||
|
use crate::filename_meta;
|
||||||
|
use crate::tmdb::TmdbKind;
|
||||||
|
|
||||||
|
// -- Title cleaner -----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Torrent titles encode a lot of release-metadata noise around the
|
||||||
|
// actual movie/show name: `[Group] Show - 117 [1080p].mkv` is typical.
|
||||||
|
// The cleaner strips that noise so the residue is something TMDB's
|
||||||
|
// search can match. Heuristic, not perfect.
|
||||||
|
|
||||||
|
/// Cleaned-up title plus extracted hints.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct CleanedTitle {
|
||||||
|
/// Best-effort name suitable for TMDB query.
|
||||||
|
pub(crate) title: String,
|
||||||
|
/// 4-digit year if one was found in the raw title (preserved
|
||||||
|
/// before stripping). Helps TMDB disambiguate remakes.
|
||||||
|
pub(crate) year: Option<u16>,
|
||||||
|
/// `Tv` when the title carries an episode marker (`S01E02`,
|
||||||
|
/// trailing `- 117`, …); `Movie` otherwise. The kind hint flips
|
||||||
|
/// which TMDB endpoint we hit.
|
||||||
|
pub(crate) kind: TmdbKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Junk tokens to drop from torrent titles when cleaning for TMDB
|
||||||
|
/// search. ASCII case-insensitive match against whitespace-split
|
||||||
|
/// tokens AND dot-separated tokens (`a.b.c` → ["a","b","c"]).
|
||||||
|
// Kept in parity with the core torznab plugin; unused in the current
|
||||||
|
// title-cleaning path (the active path uses `filename_meta`).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) const JUNK_TOKENS: &[&str] = &[
|
||||||
|
// resolutions
|
||||||
|
"1080p",
|
||||||
|
"720p",
|
||||||
|
"480p",
|
||||||
|
"2160p",
|
||||||
|
"4k",
|
||||||
|
"uhd",
|
||||||
|
"hd",
|
||||||
|
// sources
|
||||||
|
"bluray",
|
||||||
|
"bdrip",
|
||||||
|
"bdremux",
|
||||||
|
"remux",
|
||||||
|
"web-dl",
|
||||||
|
"webdl",
|
||||||
|
"webrip",
|
||||||
|
"hdtv",
|
||||||
|
"dvdrip",
|
||||||
|
"hdrip",
|
||||||
|
"dsnp",
|
||||||
|
"amzn",
|
||||||
|
"atvp",
|
||||||
|
"nf",
|
||||||
|
"hulu",
|
||||||
|
"adn",
|
||||||
|
// codecs
|
||||||
|
"x264",
|
||||||
|
"x265",
|
||||||
|
"h264",
|
||||||
|
"h265",
|
||||||
|
"hevc",
|
||||||
|
"avc",
|
||||||
|
"10bit",
|
||||||
|
"10-bit",
|
||||||
|
"8bit",
|
||||||
|
"8-bit",
|
||||||
|
// audio
|
||||||
|
"aac",
|
||||||
|
"ac3",
|
||||||
|
"flac",
|
||||||
|
"dts",
|
||||||
|
"dd5",
|
||||||
|
"ddp",
|
||||||
|
"ddp5",
|
||||||
|
"ddp7",
|
||||||
|
"atmos",
|
||||||
|
"mp3",
|
||||||
|
"opus",
|
||||||
|
"5.1",
|
||||||
|
"7.1",
|
||||||
|
"2.0",
|
||||||
|
// subs / language
|
||||||
|
"multi",
|
||||||
|
"multisub",
|
||||||
|
"multi-sub",
|
||||||
|
"multi-subs",
|
||||||
|
"vostfr",
|
||||||
|
"dual-audio",
|
||||||
|
"dualaudio",
|
||||||
|
"sub",
|
||||||
|
"subs",
|
||||||
|
"subbed",
|
||||||
|
// common file extensions
|
||||||
|
"mkv",
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"ts",
|
||||||
|
"m4v",
|
||||||
|
// misc release tags
|
||||||
|
"proper",
|
||||||
|
"repack",
|
||||||
|
"internal",
|
||||||
|
"limited",
|
||||||
|
"extended",
|
||||||
|
"uncut",
|
||||||
|
"directors",
|
||||||
|
"remastered",
|
||||||
|
"anniversary",
|
||||||
|
"ova",
|
||||||
|
"oad",
|
||||||
|
"ona",
|
||||||
|
"movie",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Clean a raw torrent title into a TMDB-searchable shape. Pipeline:
|
||||||
|
///
|
||||||
|
/// 1. Strip every `[...]` and `(...)` segment (release groups, tags).
|
||||||
|
/// 2. Strip trailing file extension.
|
||||||
|
/// 3. Detect SxxEyy markers (→ TV) and trailing ` - N` episode numbers (→ TV).
|
||||||
|
/// 4. Extract a year if one is visible.
|
||||||
|
/// 5. Split into tokens (whitespace + dots + hyphens treated as
|
||||||
|
/// separators), drop junk keywords (case-insensitive), drop
|
||||||
|
/// lone-year tokens after extraction, drop empty tokens.
|
||||||
|
/// 6. Re-assemble the survivors with single-space separators.
|
||||||
|
pub(crate) fn clean_torrent_title(raw: &str) -> CleanedTitle {
|
||||||
|
let kind = detect_kind(&strip_file_extension(&strip_bracketed(raw)));
|
||||||
|
// Year from the RAW string — `strip_bracketed` would eat a `(2023)`.
|
||||||
|
let year = extract_year(raw);
|
||||||
|
// Title via the *tested* filename-tools `cleanTitle` pipeline rather than a
|
||||||
|
// second, weaker hand-rolled cleaner. This normalizes `_`/`.` separators,
|
||||||
|
// strips the full release/quality keyword list, drops everything after the
|
||||||
|
// first " - " delimiter, and removes season-word / episode-range markers —
|
||||||
|
// all the cases the old cleaner left in, which made TMDB matching fail.
|
||||||
|
let title = filename_meta::clean_title(raw);
|
||||||
|
CleanedTitle { title, year, kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn strip_bracketed(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut depth_sq = 0i32;
|
||||||
|
let mut depth_pa = 0i32;
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'[' => depth_sq += 1,
|
||||||
|
']' => {
|
||||||
|
if depth_sq > 0 {
|
||||||
|
depth_sq -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'(' => depth_pa += 1,
|
||||||
|
')' => {
|
||||||
|
if depth_pa > 0 {
|
||||||
|
depth_pa -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if depth_sq > 0 || depth_pa > 0 => continue,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if c != '[' && c != '(' {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn strip_file_extension(s: &str) -> String {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
for ext in ["mkv", "mp4", "avi", "ts", "m4v", "webm"] {
|
||||||
|
let suffix = format!(".{ext}");
|
||||||
|
if trimmed.to_ascii_lowercase().ends_with(suffix.as_str()) {
|
||||||
|
return trimmed[..trimmed.len() - suffix.len()].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn detect_kind(s: &str) -> TmdbKind {
|
||||||
|
// Walk tokens (anything not alphanumeric is a separator —
|
||||||
|
// matches dots, spaces, brackets, hyphens). A token of shape
|
||||||
|
// S\d{1,2}, SxxEyy, EpNN, or `Season` triggers TV.
|
||||||
|
let lower = s.to_ascii_lowercase();
|
||||||
|
for tok in lower.split(|c: char| !c.is_ascii_alphanumeric()) {
|
||||||
|
if tok.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if tok == "season" {
|
||||||
|
return TmdbKind::Tv;
|
||||||
|
}
|
||||||
|
// SxxEyy or Sxx (season-only).
|
||||||
|
if let Some(rest) = tok.strip_prefix('s') {
|
||||||
|
let digits_then = rest.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||||
|
if (1..=2).contains(&digits_then) {
|
||||||
|
let after = &rest[digits_then..];
|
||||||
|
if after.is_empty() {
|
||||||
|
return TmdbKind::Tv;
|
||||||
|
}
|
||||||
|
if after.starts_with('e')
|
||||||
|
&& after[1..].chars().all(|c| c.is_ascii_digit())
|
||||||
|
&& !after[1..].is_empty()
|
||||||
|
{
|
||||||
|
return TmdbKind::Tv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trailing " - N" / " - NNN" episode-number pattern
|
||||||
|
// (common Nyaa shape: `[Group] Show - 117 [1080p].mkv`).
|
||||||
|
if has_trailing_episode_number(s) {
|
||||||
|
return TmdbKind::Tv;
|
||||||
|
}
|
||||||
|
TmdbKind::Movie
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_trailing_episode_number(s: &str) -> bool {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
// Look for ` - <digits>` near the end of the string.
|
||||||
|
if let Some(idx) = trimmed.rfind(" - ") {
|
||||||
|
let tail = trimmed[idx + 3..].trim();
|
||||||
|
// Tail is digits only, possibly followed by a `(...)` segment
|
||||||
|
// (already stripped) or `v<digit>` version suffix.
|
||||||
|
let mut digits = 0;
|
||||||
|
for c in tail.chars() {
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
digits += 1;
|
||||||
|
} else if (c == 'v' || c == 'V') && digits > 0 {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digits > 0 && digits <= 4;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // parity with core torznab; unused in the active title path
|
||||||
|
pub(crate) fn strip_episode_markers(s: &str) -> String {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
// Drop a trailing ` - N` / ` - NN` / ` - NNN` / ` - NNNN[vN]`.
|
||||||
|
if let Some(idx) = trimmed.rfind(" - ") {
|
||||||
|
let tail = &trimmed[idx + 3..];
|
||||||
|
if tail
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_digit() || c == 'v' || c == 'V')
|
||||||
|
&& tail.chars().any(|c| c.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return trimmed[..idx].trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop SxxEyy and trailing season-only markers.
|
||||||
|
let mut out = String::with_capacity(trimmed.len());
|
||||||
|
let mut chars = trimmed.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if (c == 'S' || c == 's') && chars.peek().is_some_and(|n| n.is_ascii_digit()) {
|
||||||
|
// Try to consume Sxx[Eyy].
|
||||||
|
let mut probe = String::new();
|
||||||
|
probe.push(c);
|
||||||
|
while let Some(&n) = chars.peek() {
|
||||||
|
if n.is_ascii_digit() || n == 'E' || n == 'e' {
|
||||||
|
probe.push(n);
|
||||||
|
chars.next();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate shape: starts with S, has digits, optional E + digits.
|
||||||
|
let lower = probe.to_ascii_lowercase();
|
||||||
|
let looks_like_marker = lower.starts_with('s')
|
||||||
|
&& lower[1..].chars().take(2).all(|c| c.is_ascii_digit())
|
||||||
|
&& (lower.len() == 3 || lower.contains('e'));
|
||||||
|
if looks_like_marker {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Not a marker — push back as best we can.
|
||||||
|
out.push_str(&probe);
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn extract_year(s: &str) -> Option<u16> {
|
||||||
|
// Find a 4-digit standalone token between 1900 and 2099.
|
||||||
|
for tok in s.split(|c: char| !c.is_ascii_alphanumeric()) {
|
||||||
|
if tok.len() == 4 && tok.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
if let Ok(y) = tok.parse::<u16>() {
|
||||||
|
if (1900..=2099).contains(&y) {
|
||||||
|
return Some(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // parity with core torznab; unused in the active title path
|
||||||
|
pub(crate) fn tokenize_and_filter(s: &str, extracted_year: Option<u16>) -> String {
|
||||||
|
let extracted_year_str = extracted_year.map(|y| y.to_string());
|
||||||
|
let mut kept: Vec<String> = Vec::new();
|
||||||
|
for tok in s.split(|c: char| !c.is_ascii_alphanumeric() && c != '\'') {
|
||||||
|
let trimmed = tok.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '\'');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if JUNK_TOKENS.iter().any(|j| *j == lower) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Drop the year token if we extracted it.
|
||||||
|
if let Some(y) = extracted_year_str.as_deref() {
|
||||||
|
if trimmed == y {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop pure-digit tokens longer than 2 chars (likely episode
|
||||||
|
// numbers that slipped through, or stray years not matching).
|
||||||
|
if trimmed.len() > 2 && trimmed.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
kept.join(" ").trim().to_string()
|
||||||
|
}
|
||||||
@@ -0,0 +1,851 @@
|
|||||||
|
//! TMDB HTTP client + DTOs + season/episode bounds check.
|
||||||
|
//!
|
||||||
|
//! Split out of the monolithic `torznab.rs` (pure file move; no behaviour change).
|
||||||
|
|
||||||
|
use meta_feeder_sdk::common::urlencode;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
// -- TMDB metadata enrichment ------------------------------------------------
|
||||||
|
//
|
||||||
|
// TMDB v4 bearer-token client. Two endpoints used:
|
||||||
|
//
|
||||||
|
// GET /3/search/{movie,tv}?query=<urlencoded>&include_adult=false[&year=<YYYY>]
|
||||||
|
// GET <image_base>/<poster_path>
|
||||||
|
//
|
||||||
|
// The token-bearing `Authorization` header gates both. Free-tier rate
|
||||||
|
// limit is ~40 req / 10 s — we don't hit it from a dev box but it's
|
||||||
|
// worth knowing. All TMDB failures degrade silently: enrichment is a
|
||||||
|
// best-effort augmentation, never a hard dep.
|
||||||
|
|
||||||
|
/// Lightweight TMDB v4 client. Cheap to clone (the underlying
|
||||||
|
/// `reqwest::Client` is `Arc`-backed).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TmdbClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
bearer_token: String,
|
||||||
|
api_base: String,
|
||||||
|
image_base: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmdbClient {
|
||||||
|
pub fn new(bearer_token: String) -> Self {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(TMDB_POSTER_TIMEOUT_SECS))
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.build()
|
||||||
|
.expect("rustls reqwest client build infallible");
|
||||||
|
Self {
|
||||||
|
http,
|
||||||
|
bearer_token,
|
||||||
|
api_base: TMDB_API_BASE.to_string(),
|
||||||
|
image_base: TMDB_IMAGE_BASE.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn with_bases(token: String, api_base: String, image_base: String) -> Self {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(TMDB_POSTER_TIMEOUT_SECS))
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.build()
|
||||||
|
.expect("rustls reqwest client build infallible");
|
||||||
|
Self {
|
||||||
|
http,
|
||||||
|
bearer_token: token,
|
||||||
|
api_base,
|
||||||
|
image_base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search TMDB for a movie/TV title. Returns [`TmdbCall::Hit`] with the
|
||||||
|
/// top result, [`TmdbCall::Miss`] when there are no matches (or any
|
||||||
|
/// transient/timeout error — best-effort degrade), or
|
||||||
|
/// [`TmdbCall::RateLimited`] carrying the Retry-After window on a 429 so
|
||||||
|
/// the caller can pause the shared budget globally. Wraps the whole call
|
||||||
|
/// (including JSON decode) in [`TMDB_SEARCH_TIMEOUT_SECS`].
|
||||||
|
pub(crate) async fn search(
|
||||||
|
&self,
|
||||||
|
kind: TmdbKind,
|
||||||
|
title: &str,
|
||||||
|
year: Option<u16>,
|
||||||
|
) -> TmdbCall<TmdbHit> {
|
||||||
|
let endpoint = match kind {
|
||||||
|
TmdbKind::Movie => "search/movie",
|
||||||
|
TmdbKind::Tv => "search/tv",
|
||||||
|
};
|
||||||
|
let mut url = format!(
|
||||||
|
"{}/{}?query={}&include_adult=false",
|
||||||
|
self.api_base.trim_end_matches('/'),
|
||||||
|
endpoint,
|
||||||
|
urlencode(title),
|
||||||
|
);
|
||||||
|
if let Some(y) = year {
|
||||||
|
// Movie uses `year`, TV uses `first_air_date_year`; both
|
||||||
|
// are documented but movies accept `year` for backward-
|
||||||
|
// compat which is what we want.
|
||||||
|
url.push_str(&format!("&year={y}"));
|
||||||
|
}
|
||||||
|
let fut = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&self.bearer_token)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.send();
|
||||||
|
let resp =
|
||||||
|
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), fut).await {
|
||||||
|
Ok(Ok(r)) => r,
|
||||||
|
_ => return TmdbCall::Miss,
|
||||||
|
};
|
||||||
|
if resp.status().as_u16() == 429 {
|
||||||
|
return TmdbCall::RateLimited(parse_tmdb_retry_after(&resp));
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
debug!(
|
||||||
|
target: "meta-share::gateway",
|
||||||
|
upstream = "prowlarr",
|
||||||
|
tmdb_url = %url,
|
||||||
|
status = %resp.status(),
|
||||||
|
"tmdb search non-2xx; degrading"
|
||||||
|
);
|
||||||
|
return TmdbCall::Miss;
|
||||||
|
}
|
||||||
|
let body: TmdbSearchResponse =
|
||||||
|
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), resp.json())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(b)) => b,
|
||||||
|
_ => return TmdbCall::Miss,
|
||||||
|
};
|
||||||
|
match body.results.into_iter().next() {
|
||||||
|
Some(top) => TmdbCall::Hit(top.into_hit(kind)),
|
||||||
|
None => TmdbCall::Miss,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the public TMDB poster CDN URL for `poster_path` (no fetch). In the
|
||||||
|
/// feeder model the feeder no longer fetches+stores the poster bytes; it
|
||||||
|
/// emits this URL as a `poster_url` field and the **gateway core** seeds it
|
||||||
|
/// into a content-addressed `poster` cid (same path as giphy/wikicommons
|
||||||
|
/// previews). Inserts the required [`TMDB_POSTER_SIZE`] segment.
|
||||||
|
pub(crate) fn poster_cdn_url(&self, poster_path: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}/{}",
|
||||||
|
self.image_base.trim_end_matches('/'),
|
||||||
|
TMDB_POSTER_SIZE,
|
||||||
|
poster_path.trim_start_matches('/'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a poster image's raw bytes. `poster_path` is the
|
||||||
|
/// TMDB-supplied path (typically `"/abcdef.jpg"` with leading slash).
|
||||||
|
///
|
||||||
|
/// `image_base` is the bare CDN root (`https://image.tmdb.org/t/p`); TMDB
|
||||||
|
/// requires a **size segment** (`/w500`, `/original`, …) between the root
|
||||||
|
/// and the file or it 404s. The const comment documents this contract and
|
||||||
|
/// this caller honors it by inserting [`TMDB_POSTER_SIZE`].
|
||||||
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
|
pub(crate) async fn fetch_poster(&self, poster_path: &str) -> Option<bytes::Bytes> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}/{}",
|
||||||
|
self.image_base.trim_end_matches('/'),
|
||||||
|
TMDB_POSTER_SIZE,
|
||||||
|
poster_path.trim_start_matches('/'),
|
||||||
|
);
|
||||||
|
let resp = self.http.get(&url).send().await.ok()?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
debug!(
|
||||||
|
target: "meta-share::gateway",
|
||||||
|
upstream = "prowlarr",
|
||||||
|
poster_url = %url,
|
||||||
|
status = %resp.status(),
|
||||||
|
"tmdb poster fetch non-2xx; skipping"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
resp.bytes().await.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a TV show's authoritative structure (`number_of_seasons`
|
||||||
|
/// plus the per-season `episode_count`s) via `GET /3/tv/{id}`. Used
|
||||||
|
/// only to bounds-check a title-parsed season/episode — see
|
||||||
|
/// [`season_episode_bounds`]. Wrapped in the same search timeout;
|
||||||
|
/// any failure returns `None` so validation degrades to "accept".
|
||||||
|
pub(crate) async fn tv_details(&self, tmdbid: u64) -> TmdbCall<TmdbTvDetails> {
|
||||||
|
let url = format!("{}/tv/{}", self.api_base.trim_end_matches('/'), tmdbid,);
|
||||||
|
let fut = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&self.bearer_token)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.send();
|
||||||
|
let resp =
|
||||||
|
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), fut).await {
|
||||||
|
Ok(Ok(r)) => r,
|
||||||
|
_ => return TmdbCall::Miss,
|
||||||
|
};
|
||||||
|
if resp.status().as_u16() == 429 {
|
||||||
|
return TmdbCall::RateLimited(parse_tmdb_retry_after(&resp));
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
debug!(
|
||||||
|
target: "meta-share::gateway",
|
||||||
|
upstream = "prowlarr",
|
||||||
|
tmdb_url = %url,
|
||||||
|
status = %resp.status(),
|
||||||
|
"tmdb tv-details non-2xx; skipping season/episode validation"
|
||||||
|
);
|
||||||
|
return TmdbCall::Miss;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), resp.json()).await
|
||||||
|
{
|
||||||
|
Ok(Ok(d)) => TmdbCall::Hit(d),
|
||||||
|
_ => TmdbCall::Miss,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch cross-database ids (`tvdb_id`, `imdb_id`) for a known tmdbid via
|
||||||
|
/// `GET /3/{tv,movie}/{id}/external_ids`. The anchored torznab path needs
|
||||||
|
/// `tvdb_id` to query indexers by `tvsearch&tvdbid=` (TMDB's id is not a
|
||||||
|
/// standard `tvsearch` param). Same timeout/429 contract as [`tv_details`].
|
||||||
|
pub(crate) async fn external_ids(
|
||||||
|
&self,
|
||||||
|
kind: TmdbKind,
|
||||||
|
tmdbid: u64,
|
||||||
|
) -> TmdbCall<TmdbExternalIds> {
|
||||||
|
let seg = match kind {
|
||||||
|
TmdbKind::Movie => "movie",
|
||||||
|
TmdbKind::Tv => "tv",
|
||||||
|
};
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}/{}/external_ids",
|
||||||
|
self.api_base.trim_end_matches('/'),
|
||||||
|
seg,
|
||||||
|
tmdbid,
|
||||||
|
);
|
||||||
|
self.get_json(&url, "external_ids").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a movie's canonical details via `GET /3/movie/{id}` (the anchored
|
||||||
|
/// enrichment source for a known movie tmdbid — resolves title/overview/
|
||||||
|
/// poster/year/imdb_id without a fuzzy title search). Same contract as
|
||||||
|
/// [`tv_details`].
|
||||||
|
pub(crate) async fn movie_details(&self, tmdbid: u64) -> TmdbCall<TmdbMovieDetails> {
|
||||||
|
let url = format!("{}/movie/{}", self.api_base.trim_end_matches('/'), tmdbid);
|
||||||
|
self.get_json(&url, "movie-details").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Principal search via `GET /3/search/multi` — maps a bare keyword to a
|
||||||
|
/// canonical tmdbid + media type so the gateway can then query indexers
|
||||||
|
/// structurally (the "TMDB as the front door" identification step
|
||||||
|
/// Sonarr/Radarr use). Returns the mixed movie/tv/person result list; the
|
||||||
|
/// caller picks the top-N confident anchors via [`principal_top_n`].
|
||||||
|
pub(crate) async fn search_multi(&self, query: &str) -> TmdbCall<Vec<TmdbMultiItem>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/search/multi?query={}&include_adult=false",
|
||||||
|
self.api_base.trim_end_matches('/'),
|
||||||
|
urlencode(query),
|
||||||
|
);
|
||||||
|
match self
|
||||||
|
.get_json::<TmdbMultiResponse>(&url, "search/multi")
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
TmdbCall::Hit(r) => TmdbCall::Hit(r.results),
|
||||||
|
TmdbCall::Miss => TmdbCall::Miss,
|
||||||
|
TmdbCall::RateLimited(d) => TmdbCall::RateLimited(d),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a TMDB **discovery list** (trending / popular / top-rated / a
|
||||||
|
/// `/discover` query) and map each result to a [`TmdbHit`]. Powers the
|
||||||
|
/// keyword-less discovery branch (see [`super::discovery`]): the caller
|
||||||
|
/// composes `path_and_query` (e.g. `"movie/popular"`,
|
||||||
|
/// `"trending/tv/week"`, or `"discover/tv?with_keywords=210024&sort_by=…"`)
|
||||||
|
/// and passes the `kind` so movie-vs-TV title/date fields decode correctly.
|
||||||
|
/// All these endpoints share the `{results: [TmdbSearchItem]}` shape, so one
|
||||||
|
/// method covers them. Returns the page's hits, or `Miss`/`RateLimited` under
|
||||||
|
/// the usual best-effort contract.
|
||||||
|
pub(crate) async fn discovery_list(
|
||||||
|
&self,
|
||||||
|
kind: TmdbKind,
|
||||||
|
path_and_query: &str,
|
||||||
|
) -> TmdbCall<Vec<TmdbHit>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/{}",
|
||||||
|
self.api_base.trim_end_matches('/'),
|
||||||
|
path_and_query.trim_start_matches('/'),
|
||||||
|
);
|
||||||
|
match self.get_json::<TmdbSearchResponse>(&url, "discovery").await {
|
||||||
|
TmdbCall::Hit(r) => {
|
||||||
|
TmdbCall::Hit(r.results.into_iter().map(|i| i.into_hit(kind)).collect())
|
||||||
|
}
|
||||||
|
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
|
||||||
|
/// modulo the decoded type and the log label).
|
||||||
|
async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str, what: &str) -> TmdbCall<T> {
|
||||||
|
let fut = self
|
||||||
|
.http
|
||||||
|
.get(url)
|
||||||
|
.bearer_auth(&self.bearer_token)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.send();
|
||||||
|
let resp =
|
||||||
|
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), fut).await {
|
||||||
|
Ok(Ok(r)) => r,
|
||||||
|
// Transport error / timeout. Logged at debug so the otherwise
|
||||||
|
// silent best-effort degrade is diagnosable.
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
debug!(target: "meta-share::gateway", upstream="prowlarr", tmdb_url=%url, what, error=%e, "tmdb call send error; degrading");
|
||||||
|
return TmdbCall::Miss;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
debug!(target: "meta-share::gateway", upstream="prowlarr", tmdb_url=%url, what, "tmdb call send timeout; degrading");
|
||||||
|
return TmdbCall::Miss;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if resp.status().as_u16() == 429 {
|
||||||
|
return TmdbCall::RateLimited(parse_tmdb_retry_after(&resp));
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
debug!(
|
||||||
|
target: "meta-share::gateway",
|
||||||
|
upstream = "prowlarr",
|
||||||
|
tmdb_url = %url,
|
||||||
|
status = %resp.status(),
|
||||||
|
what,
|
||||||
|
"tmdb call non-2xx; degrading"
|
||||||
|
);
|
||||||
|
return TmdbCall::Miss;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), resp.json()).await {
|
||||||
|
Ok(Ok(d)) => TmdbCall::Hit(d),
|
||||||
|
// A decode failure here folds into `Miss` (best-effort), but log it
|
||||||
|
// at debug: a single malformed element fails the whole `Vec` decode,
|
||||||
|
// which would otherwise silently disable e.g. multi-anchor.
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
debug!(target: "meta-share::gateway", upstream="prowlarr", tmdb_url=%url, what, error=%e, "tmdb call decode error; degrading");
|
||||||
|
TmdbCall::Miss
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
debug!(target: "meta-share::gateway", upstream="prowlarr", tmdb_url=%url, what, "tmdb call decode timeout; degrading");
|
||||||
|
TmdbCall::Miss
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a TMDB API call. Distinguishes a 429 rate-limit (which feeds
|
||||||
|
/// `Retry-After` into the shared [`TmdbBudget`] so all enrichment pauses
|
||||||
|
/// globally) from a plain miss or transient error (best-effort degrade).
|
||||||
|
pub(crate) enum TmdbCall<T> {
|
||||||
|
Hit(T),
|
||||||
|
Miss,
|
||||||
|
RateLimited(Duration),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a TMDB `Retry-After` header into a pause duration, defaulting to
|
||||||
|
/// [`TMDB_RATE_LIMIT_DEFAULT_RETRY_SECS`] when absent/unparseable. Mirrors
|
||||||
|
/// the indexer-path logic in [`map_status`].
|
||||||
|
pub(crate) fn parse_tmdb_retry_after(resp: &reqwest::Response) -> Duration {
|
||||||
|
let secs = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::RETRY_AFTER)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
.unwrap_or(TMDB_RATE_LIMIT_DEFAULT_RETRY_SECS);
|
||||||
|
Duration::from_secs(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum TmdbKind {
|
||||||
|
Movie,
|
||||||
|
Tv,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbSearchResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) results: Vec<TmdbSearchItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbSearchItem {
|
||||||
|
pub(crate) id: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) title: Option<String>, // movie
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) name: Option<String>, // tv
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_title: Option<String>, // movie
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_name: Option<String>, // tv
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) overview: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) release_date: Option<String>, // movie, YYYY-MM-DD
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) first_air_date: Option<String>, // tv, YYYY-MM-DD
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) poster_path: Option<String>,
|
||||||
|
#[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).
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmdbSearchItem {
|
||||||
|
pub(crate) fn into_hit(self, kind: TmdbKind) -> TmdbHit {
|
||||||
|
let title = match kind {
|
||||||
|
TmdbKind::Movie => self.title,
|
||||||
|
TmdbKind::Tv => self.name,
|
||||||
|
}
|
||||||
|
.unwrap_or_else(|| "(untitled)".to_string());
|
||||||
|
let original_title = match kind {
|
||||||
|
TmdbKind::Movie => self.original_title,
|
||||||
|
TmdbKind::Tv => self.original_name,
|
||||||
|
};
|
||||||
|
let date = match kind {
|
||||||
|
TmdbKind::Movie => self.release_date,
|
||||||
|
TmdbKind::Tv => self.first_air_date,
|
||||||
|
};
|
||||||
|
let year = date
|
||||||
|
.as_deref()
|
||||||
|
.filter(|d| d.len() >= 4)
|
||||||
|
.and_then(|d| d[..4].parse::<u16>().ok());
|
||||||
|
TmdbHit {
|
||||||
|
tmdbid: self.id,
|
||||||
|
title,
|
||||||
|
original_title,
|
||||||
|
original_language: self.original_language,
|
||||||
|
overview: self.overview,
|
||||||
|
year,
|
||||||
|
poster_path: self.poster_path,
|
||||||
|
genre_ids: self.genre_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub(crate) fn iso639_1_to_3(code: &str) -> Option<&'static str> {
|
||||||
|
Some(match code.to_ascii_lowercase().as_str() {
|
||||||
|
"en" => "eng",
|
||||||
|
"ja" => "jpn",
|
||||||
|
"fr" => "fra",
|
||||||
|
"de" => "deu",
|
||||||
|
"es" => "spa",
|
||||||
|
"it" => "ita",
|
||||||
|
"ru" => "rus",
|
||||||
|
"ko" => "kor",
|
||||||
|
"zh" => "zho",
|
||||||
|
"pt" => "por",
|
||||||
|
"nl" => "nld",
|
||||||
|
"sv" => "swe",
|
||||||
|
"no" => "nor",
|
||||||
|
"da" => "dan",
|
||||||
|
"fi" => "fin",
|
||||||
|
"pl" => "pol",
|
||||||
|
"tr" => "tur",
|
||||||
|
"ar" => "ara",
|
||||||
|
"hi" => "hin",
|
||||||
|
"th" => "tha",
|
||||||
|
"vi" => "vie",
|
||||||
|
"id" => "ind",
|
||||||
|
"cs" => "ces",
|
||||||
|
"el" => "ell",
|
||||||
|
"he" => "heb",
|
||||||
|
"hu" => "hun",
|
||||||
|
"ro" => "ron",
|
||||||
|
"uk" => "ukr",
|
||||||
|
"fa" => "fas",
|
||||||
|
"ms" => "msa",
|
||||||
|
"tl" => "tgl",
|
||||||
|
"ca" => "cat",
|
||||||
|
"nb" => "nob",
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// `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.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbHit {
|
||||||
|
pub(crate) tmdbid: u64,
|
||||||
|
pub(crate) title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_language: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) overview: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) year: Option<u16>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) poster_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)] // surfaced as CSV of ids; name resolution would need a second call
|
||||||
|
pub(crate) genre_ids: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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).
|
||||||
|
pub(crate) fn original_lang3(&self) -> Option<&'static str> {
|
||||||
|
iso639_1_to_3(self.original_language.as_deref()?.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
// `Clone` + `Serialize` added so details persist in the redb TMDB-tvdetails
|
||||||
|
// cache and can be cheaply handed around.
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbTvDetails {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) number_of_seasons: u32,
|
||||||
|
/// One entry per season TMDB knows about, including season 0
|
||||||
|
/// ("Specials"). `episode_count` is the released-episode total.
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) seasons: Vec<TmdbSeasonSummary>,
|
||||||
|
// -- Display fields ------------------------------------------------------
|
||||||
|
// Decoded from the same `GET /3/tv/{id}` payload so an anchored TV record
|
||||||
|
// (known tmdbid) enriches directly from its canonical entry instead of a
|
||||||
|
// fuzzy title search. Old cache entries (written before these fields
|
||||||
|
// existed) deserialize them to empty/None; [`TmdbTvDetails::has_display`]
|
||||||
|
// detects that so the cached-fetch path can self-heal with one refetch.
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_language: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) overview: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) first_air_date: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) poster_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmdbTvDetails {
|
||||||
|
/// True when the cached payload carries the display fields (i.e. was
|
||||||
|
/// written by the current decoder). A `false` here on a cache hit means a
|
||||||
|
/// pre-display entry — the caller refetches once to upgrade it.
|
||||||
|
pub(crate) fn has_display(&self) -> bool {
|
||||||
|
!self.name.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`TmdbHit`] (resolved by id) from the canonical TV entry, for
|
||||||
|
/// the anchored enrichment path. `None` when display fields are absent.
|
||||||
|
pub(crate) fn as_hit(&self, tmdbid: u64) -> Option<TmdbHit> {
|
||||||
|
if !self.has_display() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let year = self
|
||||||
|
.first_air_date
|
||||||
|
.as_deref()
|
||||||
|
.filter(|d| d.len() >= 4)
|
||||||
|
.and_then(|d| d[..4].parse::<u16>().ok());
|
||||||
|
Some(TmdbHit {
|
||||||
|
tmdbid,
|
||||||
|
title: self.name.clone(),
|
||||||
|
original_title: self.original_name.clone(),
|
||||||
|
original_language: self.original_language.clone(),
|
||||||
|
overview: self.overview.clone(),
|
||||||
|
year,
|
||||||
|
poster_path: self.poster_path.clone(),
|
||||||
|
genre_ids: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TMDB cross-database ids from `GET /3/{tv,movie}/{id}/external_ids`. Only the
|
||||||
|
/// two the anchored torznab path needs are decoded.
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbExternalIds {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) tvdb_id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) imdb_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authoritative movie structure from `GET /3/movie/{id}`. Decodes only the
|
||||||
|
/// display fields the anchored path needs; `imdb_id` (movie details carry it
|
||||||
|
/// inline, no separate `external_ids` call) feeds the `t=movie&imdbid=` query
|
||||||
|
/// fallback.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbMovieDetails {
|
||||||
|
pub(crate) id: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) original_language: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) overview: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) release_date: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) poster_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmdbMovieDetails {
|
||||||
|
pub(crate) fn into_hit(self) -> TmdbHit {
|
||||||
|
let year = self
|
||||||
|
.release_date
|
||||||
|
.as_deref()
|
||||||
|
.filter(|d| d.len() >= 4)
|
||||||
|
.and_then(|d| d[..4].parse::<u16>().ok());
|
||||||
|
TmdbHit {
|
||||||
|
tmdbid: self.id,
|
||||||
|
title: self.title.unwrap_or_else(|| "(untitled)".to_string()),
|
||||||
|
original_title: self.original_title,
|
||||||
|
original_language: self.original_language,
|
||||||
|
overview: self.overview,
|
||||||
|
year,
|
||||||
|
poster_path: self.poster_path,
|
||||||
|
genre_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbMultiResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) results: Vec<TmdbMultiItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `search/multi` result. `media_type` discriminates movie / tv / person;
|
||||||
|
/// we anchor only on movie/tv.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbMultiItem {
|
||||||
|
pub(crate) id: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) media_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) title: Option<String>, // movie
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) name: Option<String>, // tv
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) popularity: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmdbMultiItem {
|
||||||
|
pub(crate) fn kind(&self) -> Option<TmdbKind> {
|
||||||
|
match self.media_type.as_deref() {
|
||||||
|
Some("movie") => Some(TmdbKind::Movie),
|
||||||
|
Some("tv") => Some(TmdbKind::Tv),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn display_title(&self) -> &str {
|
||||||
|
self.title.as_deref().or(self.name.as_deref()).unwrap_or("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase alphanumeric word tokens of `s` (separators dropped). Shared by
|
||||||
|
/// the principal-search confidence check.
|
||||||
|
pub(crate) fn norm_tokens(s: &str) -> Vec<String> {
|
||||||
|
s.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.map(|t| t.to_lowercase())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick up to `n` **confident** anchors from `search/multi` hits for `query`,
|
||||||
|
/// most-popular first (or `[]` → the caller falls back to today's generic
|
||||||
|
/// `t=search&q=`). For each movie/tv hit, accept it only if its title shares at
|
||||||
|
/// least half of the query's word tokens (the per-candidate relevance gate),
|
||||||
|
/// then rank the survivors by popularity and take the top `n`. This binds a
|
||||||
|
/// vague keyword like "black" to the *several* real shows TMDB returns (Black
|
||||||
|
/// Mirror, Black Butler, Black Lagoon, …) instead of hijacking the whole query
|
||||||
|
/// onto the single most-popular title, while still dropping unrelated noise
|
||||||
|
/// (e.g. "zzz qqq www" → nothing). `n == 1` recovers the old single-anchor
|
||||||
|
/// "principal_confident" behaviour (modulo the gate being applied before the
|
||||||
|
/// popularity pick rather than after).
|
||||||
|
pub(crate) fn principal_top_n<'a>(
|
||||||
|
hits: &'a [TmdbMultiItem],
|
||||||
|
query: &str,
|
||||||
|
n: usize,
|
||||||
|
) -> Vec<&'a TmdbMultiItem> {
|
||||||
|
let q_tokens = norm_tokens(query);
|
||||||
|
if q_tokens.is_empty() || n == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut cands: Vec<&TmdbMultiItem> = hits
|
||||||
|
.iter()
|
||||||
|
.filter(|h| h.kind().is_some())
|
||||||
|
.filter(|h| {
|
||||||
|
let t_tokens = norm_tokens(h.display_title());
|
||||||
|
let overlap = q_tokens.iter().filter(|t| t_tokens.contains(t)).count();
|
||||||
|
(overlap as f64) >= (q_tokens.len() as f64) * 0.5
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
cands.sort_by(|a, b| {
|
||||||
|
b.popularity
|
||||||
|
.partial_cmp(&a.popularity)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
cands.truncate(n);
|
||||||
|
cands
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub(crate) struct TmdbSeasonSummary {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) season_number: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) episode_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verdict of [`season_episode_bounds`] — how a title-parsed `(season,
|
||||||
|
/// episode)` lines up against TMDB's actual structure.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum SeasonEpisodeBounds {
|
||||||
|
/// Consistent with TMDB, or unverifiable — keep the record as-is.
|
||||||
|
Ok,
|
||||||
|
/// The parsed season sits past TMDB's known seasons and isn't otherwise
|
||||||
|
/// listed in `seasons[]`. The canonical case is anime where fansubbers
|
||||||
|
/// number a later cour `S2` of a show TMDB models as a **single** season
|
||||||
|
/// (Frieren, Jujutsu Kaisen, …). The TMDB match itself is sound — only the
|
||||||
|
/// season number is a naming-convention artifact — so the caller keeps the
|
||||||
|
/// record but strips the misleading `season` (the frontend buckets
|
||||||
|
/// season-less episodes into a flat "Episodes" list).
|
||||||
|
SeasonOverflow,
|
||||||
|
/// A *positive* contradiction the season-overflow allowance can't explain:
|
||||||
|
/// a negative season, or an episode beyond a **matched** season's
|
||||||
|
/// `episode_count`. The title parse is wrong — the caller drops the record.
|
||||||
|
Contradiction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure bounds check: is a title-parsed `(season, episode)` consistent
|
||||||
|
/// with what TMDB says the show actually has? Deliberately lenient —
|
||||||
|
/// it only returns a verdict other than [`SeasonEpisodeBounds::Ok`] on a
|
||||||
|
/// *positive* divergence, so anything ambiguous or unverifiable is accepted:
|
||||||
|
///
|
||||||
|
/// - No parsed season → [`Ok`]. Episode-only titles (absolute-numbered
|
||||||
|
/// anime, trailing `- 117`) are legitimately unbounded; the season-
|
||||||
|
/// based bound doesn't apply.
|
||||||
|
/// - TMDB gave us no structure (`number_of_seasons == 0` and no
|
||||||
|
/// `seasons[]`) → [`Ok`]; we can't contradict what we don't know.
|
||||||
|
/// - Season exceeds `number_of_seasons` AND isn't otherwise listed in
|
||||||
|
/// `seasons[]` → [`SeasonOverflow`] (the `S2`-of-a-1-TMDB-season anime
|
||||||
|
/// case; keep but strip `season`).
|
||||||
|
/// - Negative season → [`Contradiction`].
|
||||||
|
/// - Episode exceeds the matched season's `episode_count` (when that
|
||||||
|
/// count is known) → [`Contradiction`]. This only fires for explicit
|
||||||
|
/// `SxxEyy` titles, the exact case where the per-season bound holds.
|
||||||
|
///
|
||||||
|
/// [`Ok`]: SeasonEpisodeBounds::Ok
|
||||||
|
/// [`SeasonOverflow`]: SeasonEpisodeBounds::SeasonOverflow
|
||||||
|
/// [`Contradiction`]: SeasonEpisodeBounds::Contradiction
|
||||||
|
pub(crate) fn season_episode_bounds(
|
||||||
|
details: &TmdbTvDetails,
|
||||||
|
season: Option<i64>,
|
||||||
|
episode: Option<i64>,
|
||||||
|
) -> SeasonEpisodeBounds {
|
||||||
|
use SeasonEpisodeBounds::*;
|
||||||
|
let season = match season {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok,
|
||||||
|
};
|
||||||
|
if details.number_of_seasons == 0 && details.seasons.is_empty() {
|
||||||
|
return Ok;
|
||||||
|
}
|
||||||
|
if season < 0 {
|
||||||
|
return Contradiction;
|
||||||
|
}
|
||||||
|
let matched = details.seasons.iter().find(|s| s.season_number == season);
|
||||||
|
if season > details.number_of_seasons as i64 && matched.is_none() {
|
||||||
|
return SeasonOverflow;
|
||||||
|
}
|
||||||
|
if let (Some(ep), Some(sm)) = (episode, matched) {
|
||||||
|
if sm.episode_count > 0 && ep > sm.episode_count as i64 {
|
||||||
|
return Contradiction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `episode` is a valid episode of `season` per TMDB's per-season
|
||||||
|
/// `episode_count` (the season must be listed and the count known). Used to
|
||||||
|
/// decide whether a bare title number is an in-season episode before falling
|
||||||
|
/// back to absolute-number interpretation.
|
||||||
|
pub(crate) fn episode_in_season(details: &TmdbTvDetails, season: i64, episode: i64) -> bool {
|
||||||
|
details
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.season_number == season)
|
||||||
|
.map(|s| s.episode_count > 0 && episode >= 1 && episode <= s.episode_count as i64)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::consts::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod season_bounds_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn details(num_seasons: u32, seasons: &[(i64, u32)]) -> TmdbTvDetails {
|
||||||
|
TmdbTvDetails {
|
||||||
|
number_of_seasons: num_seasons,
|
||||||
|
seasons: seasons
|
||||||
|
.iter()
|
||||||
|
.map(|&(season_number, episode_count)| TmdbSeasonSummary {
|
||||||
|
season_number,
|
||||||
|
episode_count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounds_ok_within_season() {
|
||||||
|
let d = details(2, &[(1, 26), (2, 24)]);
|
||||||
|
assert_eq!(season_episode_bounds(&d, Some(1), Some(5)), SeasonEpisodeBounds::Ok);
|
||||||
|
assert_eq!(season_episode_bounds(&d, Some(2), Some(24)), SeasonEpisodeBounds::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounds_no_season_or_unknown_structure_is_ok() {
|
||||||
|
let d = details(2, &[(1, 26), (2, 24)]);
|
||||||
|
assert_eq!(season_episode_bounds(&d, None, Some(999)), SeasonEpisodeBounds::Ok);
|
||||||
|
let unknown = details(0, &[]);
|
||||||
|
assert_eq!(season_episode_bounds(&unknown, Some(5), Some(40)), SeasonEpisodeBounds::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounds_season_past_total_is_overflow() {
|
||||||
|
// Fansub "S2" of a show TMDB models as a single season → strip-and-keep.
|
||||||
|
let d = details(1, &[(1, 28)]);
|
||||||
|
assert_eq!(season_episode_bounds(&d, Some(2), Some(3)), SeasonEpisodeBounds::SeasonOverflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounds_episode_past_matched_season_or_negative_is_contradiction() {
|
||||||
|
// "S1E27" on a 4-season show → contradiction. The enrich path drops the
|
||||||
|
// record (we no longer remap absolute numbers onto seasons; a genuine
|
||||||
|
// absolute release carries no season token and never reaches here).
|
||||||
|
let d = details(4, &[(1, 26), (2, 13), (3, 13), (4, 12)]);
|
||||||
|
assert_eq!(season_episode_bounds(&d, Some(1), Some(27)), SeasonEpisodeBounds::Contradiction);
|
||||||
|
assert_eq!(season_episode_bounds(&d, Some(-1), None), SeasonEpisodeBounds::Contradiction);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//! Process-global TMDB request budget.
|
||||||
|
//!
|
||||||
|
//! TMDB's free tier caps at ~40 requests / 10 s. The gateway can field many
|
||||||
|
//! concurrent searches, each wanting to enrich several records, so without a
|
||||||
|
//! shared throttle a burst of discover queries collectively blows the limit
|
||||||
|
//! and TMDB starts returning 429s — which surfaces as empty home rows in
|
||||||
|
//! meta-watch. This is a single hand-rolled token bucket shared by every
|
||||||
|
//! enrichment task in the process (one `Arc<TmdbBudget>` injected into the
|
||||||
|
//! torznab plugin via [`meta_feeder_sdk::plugin::FeederPlugin::set_tmdb_budget`]).
|
||||||
|
//!
|
||||||
|
//! Design notes:
|
||||||
|
//! - **Lazy refill.** Tokens accrue on each `acquire` from elapsed wall-clock,
|
||||||
|
//! so there is no background timer task and no idle cost.
|
||||||
|
//! - **Cancel-safe.** The token is decremented synchronously under the lock
|
||||||
|
//! right before `acquire` returns [`Lease::Granted`]; there is no `.await`
|
||||||
|
//! between the decision to grant and the return, so a dropped `acquire`
|
||||||
|
//! future (consumer disconnected) never spends a token.
|
||||||
|
//! - **Global 429 pause.** A single upstream 429 freezes *all* grants until
|
||||||
|
//! the Retry-After window elapses via [`TmdbBudget::note_429`].
|
||||||
|
//!
|
||||||
|
//! The critical sections hold a plain `std::sync::Mutex` (no awaits inside),
|
||||||
|
//! while the waiting happens outside the lock on a `tokio::time::sleep` raced
|
||||||
|
//! against a `tokio::sync::Notify`.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
/// Default sustained TMDB grant rate (requests/second). Held well under
|
||||||
|
/// TMDB's free-tier ~40 req / 10 s so concurrent searches plus poster-CDN
|
||||||
|
/// headroom never collectively trip a 429.
|
||||||
|
pub const DEFAULT_TMDB_REFILL_PER_SEC: f64 = 3.0;
|
||||||
|
/// Default TMDB burst ceiling (tokens) — a short burst of concurrent searches
|
||||||
|
/// is served immediately, then throttled to the sustained rate.
|
||||||
|
pub const DEFAULT_TMDB_BURST: f64 = 6.0;
|
||||||
|
|
||||||
|
/// Outcome of an [`TmdbBudget::acquire`] call.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Lease {
|
||||||
|
/// A token was granted; the caller may make one TMDB API call.
|
||||||
|
Granted,
|
||||||
|
/// The wait deadline elapsed before a token became available. The caller
|
||||||
|
/// should proceed without enrichment (best-effort degrade).
|
||||||
|
DeadlineExceeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BucketState {
|
||||||
|
tokens: f64,
|
||||||
|
capacity: f64,
|
||||||
|
refill_per_sec: f64,
|
||||||
|
last_refill: Instant,
|
||||||
|
/// When set and in the future, all grants are frozen (global 429 pause).
|
||||||
|
paused_until: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BucketState {
|
||||||
|
/// Accrue tokens for elapsed time since the last refill. Called under the
|
||||||
|
/// lock before inspecting `tokens`.
|
||||||
|
fn refill(&mut self, now: Instant) {
|
||||||
|
let elapsed = now
|
||||||
|
.saturating_duration_since(self.last_refill)
|
||||||
|
.as_secs_f64();
|
||||||
|
if elapsed > 0.0 {
|
||||||
|
self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity);
|
||||||
|
self.last_refill = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared token bucket gating TMDB API calls across the whole process.
|
||||||
|
pub struct TmdbBudget {
|
||||||
|
state: Mutex<BucketState>,
|
||||||
|
/// Woken whenever a grant *might* now be possible (a token was returned to
|
||||||
|
/// the bucket, or a 429 pause was cleared). Waiters re-check under the
|
||||||
|
/// lock after waking; a missed wake only costs an extra bounded sleep.
|
||||||
|
wake: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmdbBudget {
|
||||||
|
/// Build a budget that sustains `refill_per_sec` grants/second with a burst
|
||||||
|
/// ceiling of `capacity` tokens. The bucket starts full so the first burst
|
||||||
|
/// is served immediately.
|
||||||
|
pub fn new(refill_per_sec: f64, capacity: f64) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
state: Mutex::new(BucketState {
|
||||||
|
tokens: capacity,
|
||||||
|
capacity,
|
||||||
|
refill_per_sec,
|
||||||
|
last_refill: Instant::now(),
|
||||||
|
paused_until: None,
|
||||||
|
}),
|
||||||
|
wake: Notify::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for one token, giving up after `deadline`. Cancel-safe: dropping
|
||||||
|
/// the returned future before it resolves [`Lease::Granted`] spends no
|
||||||
|
/// token.
|
||||||
|
pub async fn acquire(&self, deadline: Duration) -> Lease {
|
||||||
|
let give_up_at = Instant::now() + deadline;
|
||||||
|
loop {
|
||||||
|
// Decide-or-compute-wait, all under the lock (no awaits inside).
|
||||||
|
let sleep_for = {
|
||||||
|
let mut st = self.state.lock().expect("tmdb budget mutex poisoned");
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Some(until) = st.paused_until {
|
||||||
|
if now >= until {
|
||||||
|
st.paused_until = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let paused = st.paused_until;
|
||||||
|
if paused.is_none() {
|
||||||
|
st.refill(now);
|
||||||
|
if st.tokens >= 1.0 {
|
||||||
|
// Atomic grant: decrement and return with no await between.
|
||||||
|
st.tokens -= 1.0;
|
||||||
|
return Lease::Granted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if now >= give_up_at {
|
||||||
|
return Lease::DeadlineExceeded;
|
||||||
|
}
|
||||||
|
let until_deadline = give_up_at.saturating_duration_since(now);
|
||||||
|
let wait = match paused {
|
||||||
|
// Frozen: wake when the pause is scheduled to lift.
|
||||||
|
Some(until) => until.saturating_duration_since(now),
|
||||||
|
// Throttled: wake when the next whole token should accrue.
|
||||||
|
None => {
|
||||||
|
let needed = 1.0 - st.tokens;
|
||||||
|
let secs = if st.refill_per_sec > 0.0 {
|
||||||
|
needed / st.refill_per_sec
|
||||||
|
} else {
|
||||||
|
until_deadline.as_secs_f64()
|
||||||
|
};
|
||||||
|
Duration::from_secs_f64(secs.max(0.0))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wait.min(until_deadline)
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(sleep_for) => {}
|
||||||
|
_ = self.wake.notified() => {}
|
||||||
|
}
|
||||||
|
if Instant::now() >= give_up_at {
|
||||||
|
return Lease::DeadlineExceeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed back an upstream 429: pause all grants until `now + retry_after`.
|
||||||
|
/// Extends an existing pause but never shortens it.
|
||||||
|
pub fn note_429(&self, retry_after: Duration) {
|
||||||
|
let until = Instant::now() + retry_after;
|
||||||
|
{
|
||||||
|
let mut st = self.state.lock().expect("tmdb budget mutex poisoned");
|
||||||
|
st.paused_until = Some(match st.paused_until {
|
||||||
|
Some(existing) if existing > until => existing,
|
||||||
|
_ => until,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Wake waiters so they recompute their sleep against the new pause.
|
||||||
|
self.wake.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn grants_up_to_capacity_immediately() {
|
||||||
|
let b = TmdbBudget::new(3.0, 3.0);
|
||||||
|
// Three tokens available at t0 — all granted without waiting.
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn fourth_token_waits_for_refill() {
|
||||||
|
let b = TmdbBudget::new(2.0, 2.0); // 2/s sustained
|
||||||
|
for _ in 0..2 {
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
|
||||||
|
}
|
||||||
|
// Bucket empty; next grant must wait ~0.5s for one token at 2/s.
|
||||||
|
let start = Instant::now();
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(10)).await, Lease::Granted);
|
||||||
|
let waited = start.elapsed();
|
||||||
|
assert!(
|
||||||
|
waited >= Duration::from_millis(450),
|
||||||
|
"expected to wait ~0.5s for refill, waited {waited:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn deadline_exceeded_when_drained() {
|
||||||
|
let b = TmdbBudget::new(0.001, 1.0); // effectively no refill within the test
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(1)).await, Lease::Granted);
|
||||||
|
// Bucket empty, refill far slower than the deadline.
|
||||||
|
assert_eq!(
|
||||||
|
b.acquire(Duration::from_millis(200)).await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn note_429_pauses_grants_globally() {
|
||||||
|
let b = TmdbBudget::new(100.0, 100.0); // plenty of tokens
|
||||||
|
b.note_429(Duration::from_secs(2));
|
||||||
|
// Even with tokens available, grants are frozen during the pause —
|
||||||
|
// a short deadline times out.
|
||||||
|
assert_eq!(
|
||||||
|
b.acquire(Duration::from_millis(500)).await,
|
||||||
|
Lease::DeadlineExceeded
|
||||||
|
);
|
||||||
|
// After the pause window, grants resume.
|
||||||
|
assert_eq!(b.acquire(Duration::from_secs(5)).await, Lease::Granted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! Tribler metainfo (file-list) fetch + parsing for the metainfo fan-out
|
||||||
|
//! (PR2 step 3). Turns one torrent infohash into the list of files it contains,
|
||||||
|
//! so `compute_outcomes` can emit one `btih-v1-file` CID per video file plus a
|
||||||
|
//! whole-torrent pack CID.
|
||||||
|
//!
|
||||||
|
//! Source: Tribler core `POST /api/torrentinfo/uri` with a JSON body
|
||||||
|
//! `{"uri": "<magnet>"}` (the `uri` query param alone 500s — the handler reads
|
||||||
|
//! the body as JSON). Response shape (`GetMetainfoResponse`):
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! { "files": [ {"index": 0, "name": "Show.S01E01.mkv", "size": 123}, … ],
|
||||||
|
//! "name": "Show Complete Season 1", "description": "", … }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The DHT metainfo resolve is **slow and may fail** (no peers / no metadata);
|
||||||
|
//! callers treat `None` as "couldn't enumerate, fall back to the single-file
|
||||||
|
//! record" — a fan-out failure must never fail the resolve.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One file inside a torrent. `index` is the zero-based position in the info
|
||||||
|
/// dict's file list — i.e. the `btih-v1-file` CID's file index. Serialized into
|
||||||
|
/// the SDK `filelist` redb cache so fan-out survives across `compute_outcomes`
|
||||||
|
/// calls without re-hitting the DHT.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct TorrentFile {
|
||||||
|
pub index: usize,
|
||||||
|
/// File path/name inside the torrent (forward-slash joined).
|
||||||
|
pub name: String,
|
||||||
|
/// File length in bytes.
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Video file extensions we fan out to (lowercase, no dot). Matches the torznab
|
||||||
|
/// feeder's `VIDEO_EXTENSIONS` so both feeders agree on what "a video file" is.
|
||||||
|
const VIDEO_EXTENSIONS: &[&str] = &[
|
||||||
|
"mkv", "mp4", "avi", "m4v", "mov", "webm", "mpg", "mpeg", "wmv", "flv", "ogv", "ts", "mts",
|
||||||
|
"m2ts",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// True if `name`'s extension is a known video container.
|
||||||
|
pub fn looks_like_video(name: &str) -> bool {
|
||||||
|
name.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.map(|ext| {
|
||||||
|
let lower = ext.to_ascii_lowercase();
|
||||||
|
VIDEO_EXTENSIONS.iter().any(|e| *e == lower)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tribler `GetMetainfoResponse.files[]` entry. Tolerant field naming: Tribler
|
||||||
|
/// uses `name`/`size`; we also accept `path`/`length` in case a tag differs.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct MiniFileInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
index: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
length: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct MetainfoResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
files: Vec<MiniFileInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Tribler `GetMetainfoResponse` body into [`TorrentFile`]s, filling a
|
||||||
|
/// fallback `index` from array position when absent.
|
||||||
|
pub fn parse_metainfo(body: &str) -> Vec<TorrentFile> {
|
||||||
|
let Ok(resp) = serde_json::from_str::<MetainfoResponse>(body) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
resp.files
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(pos, f)| {
|
||||||
|
let name = f.name.or(f.path)?;
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(TorrentFile {
|
||||||
|
index: f.index.unwrap_or(pos),
|
||||||
|
name,
|
||||||
|
size: f.size.or(f.length).unwrap_or(0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn looks_like_video_matches_extensions() {
|
||||||
|
assert!(looks_like_video("Show.S01E01.1080p.mkv"));
|
||||||
|
assert!(looks_like_video("movie.MP4"));
|
||||||
|
assert!(!looks_like_video("readme.txt"));
|
||||||
|
assert!(!looks_like_video("Pressrelease.pdf"));
|
||||||
|
assert!(!looks_like_video("noext"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_metainfo_real_tribler_shape() {
|
||||||
|
// Captured from the live Tribler `/api/torrentinfo/uri` response.
|
||||||
|
let body = r#"{"files":[
|
||||||
|
{"index":0,"name":"Big_Buck_Bunny_1080p.avi","size":928670754},
|
||||||
|
{"index":1,"name":"PROMOTE.txt","size":5008},
|
||||||
|
{"index":2,"name":"Pressrelease.pdf","size":3456234},
|
||||||
|
{"index":3,"name":"license.txt","size":180}
|
||||||
|
],"name":"Big Buck Bunny","description":""}"#;
|
||||||
|
let files = parse_metainfo(body);
|
||||||
|
assert_eq!(files.len(), 4);
|
||||||
|
assert_eq!(files[0].index, 0);
|
||||||
|
assert_eq!(files[0].name, "Big_Buck_Bunny_1080p.avi");
|
||||||
|
assert_eq!(files[0].size, 928670754);
|
||||||
|
let videos: Vec<_> = files.iter().filter(|f| looks_like_video(&f.name)).collect();
|
||||||
|
assert_eq!(videos.len(), 1, "only the .avi is video");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_metainfo_index_fallback_to_position() {
|
||||||
|
let body = r#"{"files":[{"name":"a.mkv","size":1},{"name":"b.mkv","size":2}]}"#;
|
||||||
|
let files = parse_metainfo(body);
|
||||||
|
assert_eq!(files[0].index, 0);
|
||||||
|
assert_eq!(files[1].index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_metainfo_empty_or_garbage() {
|
||||||
|
assert!(parse_metainfo("not json").is_empty());
|
||||||
|
assert!(parse_metainfo(r#"{"files":[]}"#).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
//! Feeder HTTP contract smoke test for `tribler-feeder`.
|
||||||
|
//!
|
||||||
|
//! Boots the real `TriblerPlugin` inside the SDK's `serve_feeders` harness (via
|
||||||
|
//! `router` + `configure_plugins`, so we can bind an ephemeral port) and asserts
|
||||||
|
//! the static surface: `GET /manifest` advertises the `tribler` upstream, and
|
||||||
|
//! `GET /health` is `ok`. Live search/compute are not driven here — they need a
|
||||||
|
//! running Tribler core (IPv8 swarm); that path is exercised by the dev stack's
|
||||||
|
//! bats suite against a real `tribler-instance`.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use meta_feeder_sdk::serve::{configure_plugins, router};
|
||||||
|
use tribler_feeder::tribler::TriblerPlugin;
|
||||||
|
|
||||||
|
async fn boot_feeder() -> (SocketAddr, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let plugin = TriblerPlugin::new();
|
||||||
|
let configured =
|
||||||
|
configure_plugins(vec![Box::new(plugin)], dir.path()).expect("configure");
|
||||||
|
let app = router(configured, "test".to_string(), dir.path());
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.expect("serve");
|
||||||
|
});
|
||||||
|
(addr, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manifest_advertises_tribler_and_health_ok() {
|
||||||
|
let (addr, _dir) = boot_feeder().await;
|
||||||
|
let base = format!("http://{addr}");
|
||||||
|
let http = reqwest::Client::new();
|
||||||
|
|
||||||
|
let manifest: serde_json::Value = http
|
||||||
|
.get(format!("{base}/manifest"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("manifest req")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("manifest json");
|
||||||
|
let ids: Vec<&str> = manifest["plugins"]
|
||||||
|
.as_array()
|
||||||
|
.expect("plugins array")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| p["id"].as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(ids.contains(&"tribler"), "manifest must advertise tribler, got {ids:?}");
|
||||||
|
|
||||||
|
let health: serde_json::Value = http
|
||||||
|
.get(format!("{base}/health"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("health req")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("health json");
|
||||||
|
assert_eq!(health["status"], "ok");
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.89"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
Reference in New Issue
Block a user