Transmission has no route to Prowlarr, and Prowlarr redirects a .torrent link to a magnet that a file fetch cannot follow. arr fetches the link itself and sends Transmission a magnet or the torrent bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
760 lines
25 KiB
Rust
760 lines
25 KiB
Rust
//! arr-indexer — see DESIGN.md.
|
|
|
|
mod search;
|
|
|
|
pub use search::{IndexerSearch, SearchError, SearchRelease, SearchRequest, TvSelector, TvTarget};
|
|
|
|
use std::{collections::BTreeSet, time::Duration};
|
|
|
|
use quick_xml::events::Event;
|
|
use reqwest::{header::LOCATION, Client, StatusCode, Url};
|
|
use serde::Deserialize;
|
|
use thiserror::Error;
|
|
|
|
/// How many redirects a download link may take before arr gives up. Prowlarr
|
|
/// takes one hop to a magnet or to the tracker's own file; the rest is slack.
|
|
const MAX_DOWNLOAD_REDIRECTS: usize = 5;
|
|
|
|
/// A Prowlarr indexer and the Torznab capabilities it advertises.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct Indexer {
|
|
/// Stable Prowlarr ID, retained on releases for tracker-specific rules.
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub capabilities: Capabilities,
|
|
pub capabilities_error: Option<CapabilityError>,
|
|
}
|
|
|
|
/// Search operations supported by one Torznab indexer.
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct Capabilities {
|
|
pub search: SearchMode,
|
|
pub movie: SearchMode,
|
|
pub tv: SearchMode,
|
|
}
|
|
|
|
/// One Torznab search operation and the parameters it accepts.
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct SearchMode {
|
|
pub available: bool,
|
|
pub supported_parameters: BTreeSet<String>,
|
|
}
|
|
|
|
impl SearchMode {
|
|
/// Returns whether this operation supports an ID-based lookup.
|
|
#[must_use]
|
|
pub fn supports_id_search(&self) -> bool {
|
|
self.available
|
|
&& self.supported_parameters.iter().any(|parameter| {
|
|
matches!(
|
|
parameter.as_str(),
|
|
"imdbid" | "tmdbid" | "tvdbid" | "tvmazeid" | "rid" | "traktid"
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Returns whether the indexer accepts a particular Torznab parameter.
|
|
#[must_use]
|
|
pub fn supports_parameter(&self, parameter: &str) -> bool {
|
|
self.supported_parameters.contains(parameter)
|
|
}
|
|
}
|
|
|
|
/// Client for Prowlarr's indexer and per-indexer Torznab endpoints.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ProwlarrClient {
|
|
base_url: String,
|
|
api_key: String,
|
|
client: Client,
|
|
/// Kept apart from `client` because resolving a download link depends on
|
|
/// seeing the redirect rather than following it (§7.1, issue #100).
|
|
downloads: Client,
|
|
}
|
|
|
|
impl ProwlarrClient {
|
|
/// Creates a client using Prowlarr's API key for its REST and Torznab APIs.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error when the HTTP client cannot be created.
|
|
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Result<Self, Error> {
|
|
let client = Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.map_err(|_| Error::Client)?;
|
|
let downloads = Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.map_err(|_| Error::Client)?;
|
|
|
|
Ok(Self {
|
|
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
|
api_key: api_key.into(),
|
|
client,
|
|
downloads,
|
|
})
|
|
}
|
|
|
|
/// Enumerates Prowlarr indexers and loads Torznab capabilities for each.
|
|
///
|
|
/// Indexers are requested individually so later search and seeding logic
|
|
/// retains the tracker identity instead of using Prowlarr's aggregate API.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error when Prowlarr rejects a request or a caps response is
|
|
/// not valid Torznab XML.
|
|
pub async fn indexers(&self) -> Result<Vec<Indexer>, Error> {
|
|
let response = self
|
|
.client
|
|
.get(format!("{}/api/v1/indexer", self.base_url))
|
|
.header("X-Api-Key", &self.api_key)
|
|
.send()
|
|
.await
|
|
.map_err(|error| Error::Request {
|
|
status: error.status(),
|
|
})?
|
|
.error_for_status()
|
|
.map_err(|error| Error::Request {
|
|
status: error.status(),
|
|
})?;
|
|
let indexers: Vec<ProwlarrIndexer> = response.json().await.map_err(|_| Error::Response)?;
|
|
|
|
let mut result = Vec::with_capacity(indexers.len());
|
|
for indexer in indexers.into_iter().filter(|indexer| indexer.enabled) {
|
|
let (capabilities, capabilities_error) = match self.capabilities(indexer.id).await {
|
|
Ok(capabilities) => (capabilities, None),
|
|
Err(error) => (Capabilities::default(), Some(error)),
|
|
};
|
|
result.push(Indexer {
|
|
id: indexer.id,
|
|
name: indexer.name,
|
|
capabilities,
|
|
capabilities_error,
|
|
});
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
async fn capabilities(&self, indexer_id: i64) -> Result<Capabilities, CapabilityError> {
|
|
let response = self
|
|
.client
|
|
.get(format!("{}/{indexer_id}/api", self.base_url))
|
|
.query(&[("apikey", self.api_key.as_str()), ("t", "caps")])
|
|
.send()
|
|
.await
|
|
.map_err(|error| CapabilityError::Request {
|
|
status: error.status(),
|
|
})?
|
|
.error_for_status()
|
|
.map_err(|error| CapabilityError::Request {
|
|
status: error.status(),
|
|
})?;
|
|
let body = response
|
|
.text()
|
|
.await
|
|
.map_err(|error| CapabilityError::Request {
|
|
status: error.status(),
|
|
})?;
|
|
parse_capabilities(&body)
|
|
}
|
|
|
|
/// Resolves an indexer download link into something the download client
|
|
/// can take without reaching the indexer itself.
|
|
///
|
|
/// Handing Transmission the Prowlarr link fails twice over: Transmission
|
|
/// has no route to Prowlarr, and Prowlarr answers a `.torrent` link with a
|
|
/// redirect to a magnet, which a plain file fetch cannot follow. arr has
|
|
/// the route and the API key, so it resolves the link here and passes on
|
|
/// the result (issue #100).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error when the link cannot be fetched or resolves to
|
|
/// something that is neither a magnet nor a torrent.
|
|
pub async fn download(&self, download_url: &str) -> Result<Download, DownloadError> {
|
|
if let Some(magnet) = as_magnet(download_url) {
|
|
return Ok(Download::Magnet(magnet));
|
|
}
|
|
|
|
let mut url = Url::parse(download_url).map_err(|_| DownloadError::InvalidUrl)?;
|
|
for _ in 0..MAX_DOWNLOAD_REDIRECTS {
|
|
let mut request = self.downloads.get(url.clone());
|
|
// Only ever to Prowlarr itself: a redirect can leave for a tracker
|
|
// and the key must not go with it.
|
|
if self.is_prowlarr(&url) {
|
|
request = request.header("X-Api-Key", &self.api_key);
|
|
}
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|error| DownloadError::Request {
|
|
status: error.status(),
|
|
})?;
|
|
|
|
if response.status().is_redirection() {
|
|
let location = response
|
|
.headers()
|
|
.get(LOCATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.ok_or(DownloadError::UnusableRedirect)?;
|
|
if let Some(magnet) = as_magnet(location) {
|
|
return Ok(Download::Magnet(magnet));
|
|
}
|
|
url = url
|
|
.join(location)
|
|
.map_err(|_| DownloadError::UnusableRedirect)?;
|
|
continue;
|
|
}
|
|
|
|
let body = response
|
|
.error_for_status()
|
|
.map_err(|error| DownloadError::Request {
|
|
status: error.status(),
|
|
})?
|
|
.bytes()
|
|
.await
|
|
.map_err(|error| DownloadError::Request {
|
|
status: error.status(),
|
|
})?;
|
|
|
|
// Some indexers answer with the magnet as the body rather than as
|
|
// a redirect.
|
|
if let Some(magnet) = std::str::from_utf8(&body).ok().and_then(as_magnet) {
|
|
return Ok(Download::Magnet(magnet));
|
|
}
|
|
// A torrent is a bencoded dictionary. An HTML error page or a
|
|
// login form must not reach the download client as metainfo.
|
|
if body.first() != Some(&b'd') {
|
|
return Err(DownloadError::NotATorrent);
|
|
}
|
|
return Ok(Download::Torrent(body.to_vec()));
|
|
}
|
|
|
|
Err(DownloadError::TooManyRedirects(MAX_DOWNLOAD_REDIRECTS))
|
|
}
|
|
|
|
fn is_prowlarr(&self, url: &Url) -> bool {
|
|
Url::parse(&self.base_url).is_ok_and(|base| base.origin() == url.origin())
|
|
}
|
|
}
|
|
|
|
/// What an indexer's download link resolved to.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum Download {
|
|
Magnet(String),
|
|
/// The bytes of a `.torrent` file.
|
|
Torrent(Vec<u8>),
|
|
}
|
|
|
|
fn as_magnet(value: &str) -> Option<String> {
|
|
let value = value.trim();
|
|
value
|
|
.get(..7)
|
|
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("magnet:"))
|
|
.then(|| value.to_owned())
|
|
}
|
|
|
|
/// Errors returned while discovering Prowlarr indexers.
|
|
#[derive(Debug, Error)]
|
|
pub enum Error {
|
|
#[error("could not create Prowlarr HTTP client")]
|
|
Client,
|
|
#[error("Prowlarr indexer request failed (status: {status:?})")]
|
|
Request { status: Option<StatusCode> },
|
|
#[error("could not parse Prowlarr indexer response")]
|
|
Response,
|
|
}
|
|
|
|
/// A download link that could not be turned into a magnet or a torrent.
|
|
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
|
pub enum DownloadError {
|
|
#[error("download link request failed (status: {status:?})")]
|
|
Request { status: Option<StatusCode> },
|
|
#[error("download link is not a valid URL")]
|
|
InvalidUrl,
|
|
#[error("download link redirected to a location that cannot be used")]
|
|
UnusableRedirect,
|
|
#[error("download link returned neither a torrent nor a magnet")]
|
|
NotATorrent,
|
|
#[error("download link still redirected after {0} hops")]
|
|
TooManyRedirects(usize),
|
|
}
|
|
|
|
/// A failed capabilities request for an otherwise usable Prowlarr indexer.
|
|
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
|
pub enum CapabilityError {
|
|
#[error("capabilities request failed (status: {status:?})")]
|
|
Request { status: Option<StatusCode> },
|
|
#[error("capabilities response was not valid Torznab XML")]
|
|
InvalidResponse,
|
|
#[error("tracker returned Torznab error {code:?}: {description}")]
|
|
Torznab {
|
|
code: Option<u16>,
|
|
description: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ProwlarrIndexer {
|
|
id: i64,
|
|
name: String,
|
|
#[serde(rename = "enable")]
|
|
enabled: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TorznabCapabilities {
|
|
#[serde(default)]
|
|
searching: Option<TorznabSearching>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TorznabError {
|
|
#[serde(default, rename = "@code")]
|
|
code: Option<u16>,
|
|
#[serde(default, rename = "@description")]
|
|
description: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TorznabSearching {
|
|
#[serde(default)]
|
|
search: Option<TorznabSearchMode>,
|
|
#[serde(default, rename = "tv-search")]
|
|
tv: Option<TorznabSearchMode>,
|
|
#[serde(default, rename = "movie-search")]
|
|
movie: Option<TorznabSearchMode>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TorznabSearchMode {
|
|
#[serde(default, rename = "@available")]
|
|
available: Option<String>,
|
|
#[serde(default, rename = "@supportedParams")]
|
|
supported_parameters: Option<String>,
|
|
}
|
|
|
|
fn parse_capabilities(body: &str) -> Result<Capabilities, CapabilityError> {
|
|
if response_root(body)? == "error" {
|
|
let error: TorznabError =
|
|
quick_xml::de::from_str(body).map_err(|_| CapabilityError::InvalidResponse)?;
|
|
return Err(CapabilityError::Torznab {
|
|
code: error.code,
|
|
description: error.description,
|
|
});
|
|
}
|
|
|
|
let capabilities: TorznabCapabilities =
|
|
quick_xml::de::from_str(body).map_err(|_| CapabilityError::InvalidResponse)?;
|
|
let searching = capabilities.searching;
|
|
|
|
Ok(Capabilities {
|
|
search: searching
|
|
.as_ref()
|
|
.and_then(|modes| modes.search.as_ref())
|
|
.map_or_else(SearchMode::default, SearchMode::from),
|
|
movie: searching
|
|
.as_ref()
|
|
.and_then(|modes| modes.movie.as_ref())
|
|
.map_or_else(SearchMode::default, SearchMode::from),
|
|
tv: searching
|
|
.as_ref()
|
|
.and_then(|modes| modes.tv.as_ref())
|
|
.map_or_else(SearchMode::default, SearchMode::from),
|
|
})
|
|
}
|
|
|
|
fn response_root(body: &str) -> Result<String, CapabilityError> {
|
|
let mut reader = quick_xml::Reader::from_str(body);
|
|
loop {
|
|
match reader
|
|
.read_event()
|
|
.map_err(|_| CapabilityError::InvalidResponse)?
|
|
{
|
|
Event::Start(element) | Event::Empty(element) => {
|
|
return String::from_utf8(element.name().as_ref().to_owned())
|
|
.map_err(|_| CapabilityError::InvalidResponse);
|
|
}
|
|
Event::Eof => return Err(CapabilityError::InvalidResponse),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&TorznabSearchMode> for SearchMode {
|
|
fn from(mode: &TorznabSearchMode) -> Self {
|
|
let supported_parameters = mode
|
|
.supported_parameters
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|parameter| !parameter.is_empty())
|
|
.map(str::to_owned)
|
|
.collect();
|
|
|
|
Self {
|
|
available: mode.available.as_deref().is_some_and(is_available),
|
|
supported_parameters,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_available(value: &str) -> bool {
|
|
matches!(
|
|
value.trim().to_ascii_lowercase().as_str(),
|
|
"yes" | "true" | "1"
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
parse_capabilities, Capabilities, CapabilityError, Download, DownloadError, ProwlarrClient,
|
|
SearchRequest, TvSelector, TvTarget,
|
|
};
|
|
use reqwest::StatusCode;
|
|
use wiremock::{
|
|
matchers::{header, method, path, query_param},
|
|
Mock, MockServer, ResponseTemplate,
|
|
};
|
|
|
|
const API_KEY: &str = "test-api-key";
|
|
|
|
fn client(server: &MockServer) -> ProwlarrClient {
|
|
ProwlarrClient::new(server.uri(), API_KEY).expect("HTTP client can be created")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enumerates_indexers_with_recorded_capabilities() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/v1/indexer"))
|
|
.and(header("X-Api-Key", API_KEY))
|
|
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
|
include_str!("../tests/fixtures/indexers.json"),
|
|
"application/json",
|
|
))
|
|
.mount(&server)
|
|
.await;
|
|
mount_capabilities(&server, 3, include_str!("../tests/fixtures/alpha-caps.xml")).await;
|
|
mount_capabilities(
|
|
&server,
|
|
17,
|
|
include_str!("../tests/fixtures/text-only-caps.xml"),
|
|
)
|
|
.await;
|
|
mount_capabilities(
|
|
&server,
|
|
23,
|
|
include_str!("../tests/fixtures/broken-caps.xml"),
|
|
)
|
|
.await;
|
|
mount_failure(&server, 29).await;
|
|
|
|
let indexers = ProwlarrClient::new(server.uri(), API_KEY)
|
|
.expect("HTTP client can be created")
|
|
.indexers()
|
|
.await
|
|
.expect("recorded Prowlarr responses are valid");
|
|
|
|
assert_eq!(indexers.len(), 4);
|
|
assert_eq!(indexers[0].id, 3);
|
|
assert_eq!(indexers[0].name, "Alpha Tracker");
|
|
assert!(indexers[0].capabilities.movie.available);
|
|
assert!(indexers[0].capabilities.movie.supports_id_search());
|
|
assert!(indexers[0].capabilities.tv.supports_parameter("season"));
|
|
|
|
assert_eq!(indexers[1].id, 17);
|
|
assert_eq!(indexers[1].name, "Text Only");
|
|
assert!(indexers[1].capabilities.search.available);
|
|
assert!(!indexers[1].capabilities.search.supports_id_search());
|
|
assert!(!indexers[1].capabilities.movie.supports_id_search());
|
|
assert!(!indexers[1].capabilities.tv.available);
|
|
assert!(!indexers[1].capabilities.tv.supports_id_search());
|
|
|
|
assert_eq!(indexers[2].id, 23);
|
|
assert_eq!(indexers[2].capabilities, Capabilities::default());
|
|
assert_eq!(
|
|
indexers[2].capabilities_error,
|
|
Some(CapabilityError::Torznab {
|
|
code: Some(100),
|
|
description: "Indexer unavailable".to_owned(),
|
|
})
|
|
);
|
|
|
|
assert_eq!(indexers[3].id, 29);
|
|
assert_eq!(
|
|
indexers[3].capabilities_error,
|
|
Some(CapabilityError::Request {
|
|
status: Some(StatusCode::SERVICE_UNAVAILABLE),
|
|
})
|
|
);
|
|
}
|
|
|
|
/// #100: Prowlarr answers a `.torrent` link with a redirect to a magnet.
|
|
/// The download client cannot follow that, so arr does it here.
|
|
#[tokio::test]
|
|
async fn a_redirect_to_a_magnet_resolves_to_the_magnet() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/download"))
|
|
.and(header("X-Api-Key", API_KEY))
|
|
.respond_with(
|
|
ResponseTemplate::new(302)
|
|
.insert_header("location", "magnet:?xt=urn:btih:abc&dn=release"),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let download = client(&server)
|
|
.download(&format!("{}/download", server.uri()))
|
|
.await
|
|
.expect("the redirect resolves");
|
|
|
|
assert_eq!(
|
|
download,
|
|
Download::Magnet("magnet:?xt=urn:btih:abc&dn=release".to_owned())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_torrent_body_resolves_to_its_bytes() {
|
|
let body: &[u8] = b"d4:infod6:lengthi1e4:name7:one.mkvee";
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/download"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/x-bittorrent"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let download = client(&server)
|
|
.download(&format!("{}/download", server.uri()))
|
|
.await
|
|
.expect("the body is a torrent");
|
|
|
|
assert_eq!(download, Download::Torrent(body.to_vec()));
|
|
}
|
|
|
|
/// A magnet in the feed needs no request at all.
|
|
#[tokio::test]
|
|
async fn a_magnet_link_resolves_without_a_request() {
|
|
let server = MockServer::start().await;
|
|
|
|
let download = client(&server)
|
|
.download("magnet:?xt=urn:btih:abc")
|
|
.await
|
|
.expect("a magnet is already resolved");
|
|
|
|
assert_eq!(
|
|
download,
|
|
Download::Magnet("magnet:?xt=urn:btih:abc".to_owned())
|
|
);
|
|
assert!(server
|
|
.received_requests()
|
|
.await
|
|
.expect("recorded")
|
|
.is_empty());
|
|
}
|
|
|
|
/// The API key belongs to Prowlarr. A redirect that leaves for a tracker
|
|
/// must not carry it there.
|
|
#[tokio::test]
|
|
async fn the_api_key_never_follows_a_redirect_off_prowlarr() {
|
|
let tracker = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/file.torrent"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_raw(b"d4:infodee".as_slice(), "application/x-bittorrent"),
|
|
)
|
|
.mount(&tracker)
|
|
.await;
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/download"))
|
|
.respond_with(
|
|
ResponseTemplate::new(302)
|
|
.insert_header("location", format!("{}/file.torrent", tracker.uri())),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let download = client(&server)
|
|
.download(&format!("{}/download", server.uri()))
|
|
.await
|
|
.expect("the tracker serves the file");
|
|
|
|
assert_eq!(download, Download::Torrent(b"d4:infodee".to_vec()));
|
|
let sent_key = tracker
|
|
.received_requests()
|
|
.await
|
|
.expect("recorded")
|
|
.into_iter()
|
|
.any(|request| request.headers.contains_key("x-api-key"));
|
|
assert!(!sent_key);
|
|
}
|
|
|
|
/// An HTML error page or a login form is not a torrent, and passing it on
|
|
/// as metainfo would only fail later and less clearly.
|
|
#[tokio::test]
|
|
async fn a_body_that_is_not_a_torrent_is_refused() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/download"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("<html>login</html>"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let error = client(&server)
|
|
.download(&format!("{}/download", server.uri()))
|
|
.await
|
|
.expect_err("HTML is not a torrent");
|
|
|
|
assert_eq!(error, DownloadError::NotATorrent);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_redirect_loop_gives_up() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/download"))
|
|
.respond_with(ResponseTemplate::new(302).insert_header("location", "/download"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let error = client(&server)
|
|
.download(&format!("{}/download", server.uri()))
|
|
.await
|
|
.expect_err("the link never resolves");
|
|
|
|
assert_eq!(error, DownloadError::TooManyRedirects(5));
|
|
}
|
|
|
|
#[test]
|
|
fn tv_requests_degrade_to_what_each_indexer_accepts() {
|
|
let alpha = capabilities(include_str!("../tests/fixtures/alpha-caps.xml"));
|
|
let season_only = capabilities(include_str!("../tests/fixtures/season-only-caps.xml"));
|
|
let text_only = capabilities(include_str!("../tests/fixtures/text-only-caps.xml"));
|
|
|
|
let episode = TvTarget {
|
|
tvdb_id: Some(371_980),
|
|
title: "Example Show".to_owned(),
|
|
selector: TvSelector::Episode {
|
|
season: 2,
|
|
episode: 3,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
alpha.tv_request(&episode),
|
|
Some(SearchRequest::Tv {
|
|
tvdb_id: 371_980,
|
|
selector: TvSelector::Episode {
|
|
season: 2,
|
|
episode: 3,
|
|
},
|
|
})
|
|
);
|
|
assert_eq!(
|
|
season_only.tv_request(&episode),
|
|
Some(SearchRequest::Tv {
|
|
tvdb_id: 371_980,
|
|
selector: TvSelector::Season { season: 2 },
|
|
}),
|
|
"an indexer without ep gets the season and filters locally"
|
|
);
|
|
assert_eq!(
|
|
text_only.tv_request(&episode),
|
|
Some(SearchRequest::Text {
|
|
query: "Example Show S02E03".to_owned(),
|
|
})
|
|
);
|
|
assert_eq!(Capabilities::default().tv_request(&episode), None);
|
|
|
|
let untracked = TvTarget {
|
|
tvdb_id: None,
|
|
..episode.clone()
|
|
};
|
|
assert_eq!(
|
|
alpha.tv_request(&untracked),
|
|
Some(SearchRequest::Text {
|
|
query: "Example Show S02E03".to_owned(),
|
|
}),
|
|
"no TVDB ID means text search even where tvdbid is supported"
|
|
);
|
|
|
|
let daily = TvTarget {
|
|
selector: TvSelector::Daily {
|
|
year: 2026,
|
|
month: 8,
|
|
day: 21,
|
|
},
|
|
..episode.clone()
|
|
};
|
|
assert_eq!(
|
|
alpha.tv_request(&daily),
|
|
Some(SearchRequest::Tv {
|
|
tvdb_id: 371_980,
|
|
selector: TvSelector::Daily {
|
|
year: 2026,
|
|
month: 8,
|
|
day: 21,
|
|
},
|
|
})
|
|
);
|
|
assert_eq!(
|
|
season_only.tv_request(&daily),
|
|
Some(SearchRequest::Tv {
|
|
tvdb_id: 371_980,
|
|
selector: TvSelector::Series,
|
|
}),
|
|
"an air date is not a season number, so it widens to the series"
|
|
);
|
|
assert_eq!(
|
|
text_only.tv_request(&daily),
|
|
Some(SearchRequest::Text {
|
|
query: "Example Show 2026 08 21".to_owned(),
|
|
})
|
|
);
|
|
|
|
let season = TvTarget {
|
|
selector: TvSelector::Season { season: 2 },
|
|
..episode
|
|
};
|
|
assert_eq!(
|
|
text_only.tv_request(&season),
|
|
Some(SearchRequest::Text {
|
|
query: "Example Show S02".to_owned(),
|
|
})
|
|
);
|
|
}
|
|
|
|
fn capabilities(body: &str) -> Capabilities {
|
|
parse_capabilities(body).expect("fixture is valid Torznab caps XML")
|
|
}
|
|
|
|
async fn mount_capabilities(server: &MockServer, id: i64, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/{id}/api")))
|
|
.and(query_param("apikey", API_KEY))
|
|
.and(query_param("t", "caps"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/xml"))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_failure(server: &MockServer, id: i64) {
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/{id}/api")))
|
|
.and(query_param("apikey", API_KEY))
|
|
.and(query_param("t", "caps"))
|
|
.respond_with(ResponseTemplate::new(503))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
}
|