One empty-query Torznab call per indexer every ten minutes, matched against the whole wanted list locally (DESIGN.md §6.2). Matching is conservative: a TMDB id the tracker supplied wins outright, a title needs its year, and an ambiguity is not a match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
3.5 KiB
Rust
99 lines
3.5 KiB
Rust
//! The searchable indexer list, discovered once and reused. See DESIGN.md
|
|
//! §6.1.
|
|
//!
|
|
//! Prowlarr enumerates one indexer per call and probes `t=caps` for each,
|
|
//! sequentially, so both the 30 s grab lane and the 10 min RSS lane would
|
|
//! spend most of their budget rediscovering the same list. Each lane keeps
|
|
//! its own directory: they run concurrently and neither should wait on the
|
|
//! other's HTTP calls.
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use arr_indexer::ProwlarrClient;
|
|
|
|
/// How long a discovered indexer list is reused.
|
|
const CACHE_TTL: Duration = Duration::from_mins(15);
|
|
|
|
/// The share of a tick's budget discovery may spend. One unresponsive
|
|
/// tracker must not cancel the whole action before a single search has run;
|
|
/// past this the last known list is used instead.
|
|
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(8);
|
|
|
|
/// A discovery that produced nothing usable at all.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum DiscoveryError {
|
|
#[error("prowlarr: {0}")]
|
|
Prowlarr(#[from] arr_indexer::Error),
|
|
#[error("indexer discovery timed out after {0:?} and none is known yet")]
|
|
Timeout(Duration),
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct Cache {
|
|
ids: Vec<i64>,
|
|
refreshed_at: Option<Instant>,
|
|
}
|
|
|
|
/// The indexers that accept a text search, cached across ticks.
|
|
#[derive(Debug)]
|
|
pub struct IndexerDirectory {
|
|
prowlarr: ProwlarrClient,
|
|
cache: tokio::sync::RwLock<Cache>,
|
|
/// [`DISCOVERY_TIMEOUT`], overridden by tests that cannot wait out the
|
|
/// real one. Mirrors `ReconcileLoop`'s action timeout override.
|
|
pub(crate) discovery_timeout: Duration,
|
|
}
|
|
|
|
impl IndexerDirectory {
|
|
pub fn new(prowlarr: ProwlarrClient) -> Self {
|
|
Self {
|
|
prowlarr,
|
|
cache: tokio::sync::RwLock::new(Cache::default()),
|
|
discovery_timeout: DISCOVERY_TIMEOUT,
|
|
}
|
|
}
|
|
|
|
/// The ids of every indexer advertising a text search — which is also
|
|
/// the RSS feed, an empty-query text search (§6.2).
|
|
///
|
|
/// A refresh that fails or times out keeps the previous list rather than
|
|
/// stopping the tick, and only an empty cache turns that into an error.
|
|
pub async fn searchable(&self) -> Result<Vec<i64>, DiscoveryError> {
|
|
{
|
|
let cache = self.cache.read().await;
|
|
if cache
|
|
.refreshed_at
|
|
.is_some_and(|at| at.elapsed() < CACHE_TTL)
|
|
{
|
|
return Ok(cache.ids.clone());
|
|
}
|
|
}
|
|
|
|
let discovered =
|
|
tokio::time::timeout(self.discovery_timeout, self.prowlarr.indexers()).await;
|
|
let mut cache = self.cache.write().await;
|
|
match discovered {
|
|
Ok(Ok(indexers)) => {
|
|
cache.ids = indexers
|
|
.iter()
|
|
.filter(|indexer| indexer.capabilities.search.available)
|
|
.map(|indexer| indexer.id)
|
|
.collect();
|
|
cache.refreshed_at = Some(Instant::now());
|
|
}
|
|
Ok(Err(error)) if cache.ids.is_empty() => return Err(error.into()),
|
|
Err(_) if cache.ids.is_empty() => {
|
|
return Err(DiscoveryError::Timeout(self.discovery_timeout))
|
|
}
|
|
Ok(Err(error)) => {
|
|
tracing::warn!(%error, "indexer discovery failed; using the last known list");
|
|
}
|
|
Err(_) => tracing::warn!(
|
|
timeout_seconds = self.discovery_timeout.as_secs(),
|
|
"indexer discovery timed out; using the last known list"
|
|
),
|
|
}
|
|
Ok(cache.ids.clone())
|
|
}
|
|
}
|