inital commit
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user