Files
arr/crates/arr-daemon/src/indexers.rs
T
Miguel PalhasandClaude Opus 5 fd4d409c8d
ci / web (pull_request) Successful in 25s
e2e / e2e (pull_request) Successful in 55s
ci / rust (pull_request) Successful in 2m12s
feat: RSS sync worker
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>
2026-08-23 00:55:39 +01:00

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())
}
}