fix(dl): resolve download links in arr (#100)
ci / web (push) Successful in 36s
e2e / e2e (push) Successful in 1m56s
ci / rust (push) Successful in 4m11s

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>
This commit is contained in:
Miguel Palhas
2026-08-23 08:00:11 +01:00
parent 5db3a8d7a4
commit 4afe44c812
7 changed files with 453 additions and 44 deletions
+1
View File
@@ -36,6 +36,7 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
base64 = { workspace = true }
tempfile = { workspace = true }
wiremock = { workspace = true }
+166 -31
View File
@@ -22,7 +22,7 @@ use arr_core::policy::{evaluate, Candidate};
use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict};
use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient};
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest};
use arr_meta::TmdbClient;
use crate::indexers::{DiscoveryError, IndexerDirectory};
@@ -78,6 +78,8 @@ pub enum GrabError {
InvalidTmdbId(i64),
#[error("transmission: {0}")]
Transmission(#[from] arr_dl::Error),
#[error("download link: {0}")]
Download(#[from] arr_indexer::DownloadError),
#[error("release {name}: {source}")]
Parsed {
name: String,
@@ -105,8 +107,8 @@ impl GrabAction {
) -> Self {
Self {
indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
prowlarr,
grabber: Grabber::new(transmission, download_dir, seeding),
tmdb: None,
}
}
@@ -380,6 +382,9 @@ impl GrabAction {
/// release onward they are the same writes, so they share this.
#[derive(Debug)]
pub(crate) struct Grabber {
/// Resolves the winner's indexer link before it is sent on: Transmission
/// cannot reach Prowlarr and arr can (issue #100).
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
download_dir: PathBuf,
seeding: SeedingRules,
@@ -441,11 +446,13 @@ impl GrabScope {
impl Grabber {
pub(crate) fn new(
prowlarr: ProwlarrClient,
transmission: TransmissionClient,
download_dir: PathBuf,
seeding: SeedingRules,
) -> Self {
Self {
prowlarr,
transmission,
download_dir,
seeding,
@@ -553,6 +560,30 @@ impl Grabber {
}
}
/// Resolve the winner's indexer link and add it to Transmission.
///
/// The link is resolved here rather than passed on, because Transmission
/// has no route to Prowlarr and cannot follow its redirect to a magnet
/// (issue #100).
async fn send_to_transmission(
&self,
winner: &Eligible,
loaded: &MoviePolicy,
) -> Result<arr_dl::AddedTorrent, GrabError> {
let seeding = self.seeding.for_indexer(winner.indexer_id);
let source = torrent_source(self.prowlarr.download(&winner.download_url).await?);
Ok(self
.transmission
.add_torrent(AddTorrent {
source,
label: label(loaded),
download_dir: self.download_dir.clone(),
seed_ratio_limit: seeding.ratio,
seed_idle_limit_minutes: seeding.idle_minutes,
})
.await?)
}
/// Add the winning release to Transmission and record the grab.
///
/// The search still counts as an attempt (§6.2) on every exit that is
@@ -567,22 +598,11 @@ impl Grabber {
blacklist: &Blacklist,
winner: Eligible,
) -> Result<Option<Outcome>, GrabError> {
let seeding = self.seeding.for_indexer(winner.indexer_id);
let added = match self
.transmission
.add_torrent(AddTorrent {
source: torrent_source(&winner.download_url),
label: label(loaded),
download_dir: self.download_dir.clone(),
seed_ratio_limit: seeding.ratio,
seed_idle_limit_minutes: seeding.idle_minutes,
})
.await
{
let added = match self.send_to_transmission(&winner, loaded).await {
Ok(added) => added,
Err(error) => {
self.record_attempt(database, target).await?;
return Err(error.into());
return Err(error);
}
};
let infohash = added.hash.to_ascii_lowercase();
@@ -1120,13 +1140,11 @@ pub(crate) fn label_for_root(kind: &str, audience: &str) -> String {
format!("{kind}-{audience}")
}
/// Indexers hand out magnets and `.torrent` links interchangeably;
/// Transmission takes either in the same field.
fn torrent_source(download_url: &str) -> TorrentSource {
if download_url.starts_with("magnet:") {
TorrentSource::Magnet(download_url.to_owned())
} else {
TorrentSource::Url(download_url.to_owned())
/// A resolved download, in the shape Transmission takes it.
fn torrent_source(download: Download) -> TorrentSource {
match download {
Download::Magnet(uri) => TorrentSource::Magnet(uri),
Download::Torrent(bytes) => TorrentSource::Metainfo(bytes),
}
}
@@ -1151,9 +1169,61 @@ fn rfc3339(time: SystemTime) -> Option<String> {
chrono::DateTime::from_timestamp(seconds, 0).map(|date| date.to_rfc3339())
}
/// Fixture support for the download-link resolve step (issue #100). Grab
/// fixtures carry indexer links, and every lane now fetches them, so the mock
/// indexer has to answer the way Prowlarr does.
#[cfg(test)]
#[allow(clippy::unwrap_used)]
pub(crate) mod test_downloads {
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
/// Where rewritten fixture links point on the mock indexer.
const PREFIX: &str = "/dl/";
/// Rewrites the download links in a recorded feed onto `server`.
pub(crate) fn rewrite(feed: &str, links: &str, server: &MockServer) -> String {
feed.replace(links, &format!("{}{PREFIX}", server.uri()))
}
/// Prowlarr's live behaviour: the download endpoint 302s to a magnet.
pub(crate) async fn mount(server: &MockServer) {
Mock::given(method("GET"))
.and(path_regex(format!("^{PREFIX}")))
.respond_with(MagnetRedirect)
.mount(server)
.await;
}
struct MagnetRedirect;
impl Respond for MagnetRedirect {
fn respond(&self, request: &Request) -> ResponseTemplate {
let name = request
.url
.path()
.rsplit('/')
.next()
.unwrap_or_default()
.to_owned();
// Deterministic stand-in for the infohash the tracker would give,
// and distinct per release so duplicates still collapse.
let mut hash: u64 = 5381;
for byte in name.as_bytes() {
hash = hash.wrapping_mul(33) ^ u64::from(*byte);
}
ResponseTemplate::new(302).insert_header(
"location",
format!("magnet:?xt=urn:btih:{hash:040x}&dn={name}"),
)
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use base64::Engine as _;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -1192,8 +1262,11 @@ mod tests {
}
fn add(&self, arguments: &Value) -> ResponseTemplate {
// A magnet arrives as `filename`, a torrent body as base64
// `metainfo` — either identifies the torrent for the fake.
let source = arguments["filename"]
.as_str()
.or_else(|| arguments["metainfo"].as_str())
.unwrap_or_default()
.to_owned();
let labels: Vec<String> = arguments["labels"]
@@ -1317,13 +1390,21 @@ mod tests {
async fn prowlarr() -> MockServer {
let server = MockServer::start().await;
let feed = test_downloads::rewrite(RSS, "https://tracker/", &server);
test_downloads::mount(&server).await;
mount_indexer(&server, &feed).await;
server
}
/// One indexer advertising a text search, serving `feed` to every query.
async fn mount_indexer(server: &MockServer, feed: &str) {
Mock::given(method("GET"))
.and(path("/api/v1/indexer"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!([{"id": 7, "name": "tracker", "enable": true}])),
)
.mount(&server)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/7/api"))
@@ -1331,15 +1412,14 @@ mod tests {
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"<caps><searching><search available="yes" supportedParams="q"/></searching></caps>"#,
))
.mount(&server)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/7/api"))
.and(query_param("t", "search"))
.respond_with(ResponseTemplate::new(200).set_body_string(RSS))
.mount(&server)
.respond_with(ResponseTemplate::new(200).set_body_string(feed))
.mount(server)
.await;
server
}
async fn empty_prowlarr() -> MockServer {
@@ -2004,15 +2084,70 @@ mod tests {
assert!(fake.torrents().is_empty());
}
/// §100: Transmission has no route to the indexer, so a download link
/// that answers with the torrent itself is forwarded as inline metainfo
/// and the link never leaves arr.
#[tokio::test]
async fn a_torrent_body_is_forwarded_inline_and_the_link_never_leaves_arr() {
const TORRENT: &[u8] = b"d4:infod6:lengthi1e4:name8:good.mkvee";
let (_dir, database) = wanted_movie().await;
let indexer = MockServer::start().await;
let feed = RSS.replace("https://tracker/", &format!("{}/dl/", indexer.uri()));
mount_indexer(&indexer, &feed).await;
Mock::given(method("GET"))
.and(path("/dl/good.torrent"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(TORRENT, "application/x-bittorrent"),
)
.mount(&indexer)
.await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
let sent = base64::engine::general_purpose::STANDARD
.decode(&fake.torrents()[0].source)
.expect("the torrent is sent as base64 metainfo");
assert_eq!(sent, TORRENT);
let fetched_with_key = indexer
.received_requests()
.await
.unwrap()
.into_iter()
.filter(|request| request.url.path() == "/dl/good.torrent")
.all(|request| request.headers.contains_key("x-api-key"));
assert!(fetched_with_key, "arr resolves the link with its own key");
}
/// A link that redirects to a magnet — Prowlarr's usual answer — reaches
/// Transmission as the magnet, which it can act on without the indexer.
#[tokio::test]
async fn a_link_that_redirects_to_a_magnet_is_sent_as_the_magnet() {
let (_dir, database) = wanted_movie().await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
assert!(
fake.torrents()[0]
.source
.starts_with("magnet:?xt=urn:btih:"),
"{}",
fake.torrents()[0].source
);
}
#[test]
fn indexer_links_and_magnets_both_reach_transmission() {
fn a_resolved_download_keeps_its_shape() {
assert_eq!(
torrent_source("magnet:?xt=urn:btih:abc"),
torrent_source(Download::Magnet("magnet:?xt=urn:btih:abc".into())),
TorrentSource::Magnet("magnet:?xt=urn:btih:abc".into())
);
assert_eq!(
torrent_source("https://tracker/good.torrent"),
TorrentSource::Url("https://tracker/good.torrent".into())
torrent_source(Download::Torrent(b"d4:infodee".to_vec())),
TorrentSource::Metainfo(b"d4:infodee".to_vec())
);
}
}
+5 -3
View File
@@ -44,8 +44,8 @@ impl RssAction {
) -> Self {
Self {
indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
prowlarr,
grabber: Grabber::new(transmission, download_dir, seeding),
}
}
@@ -304,7 +304,7 @@ mod tests {
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use super::*;
use crate::grab::SeedingLimits;
use crate::grab::{test_downloads, SeedingLimits};
use arr_dl::TransmissionClient;
/// The recorded feed: two releases of one wanted title, one that only a
@@ -352,6 +352,8 @@ mod tests {
/// feed to an empty query.
async fn prowlarr() -> MockServer {
let server = MockServer::start().await;
let feed = test_downloads::rewrite(FEED, "https://indexer.invalid/download/", &server);
test_downloads::mount(&server).await;
Mock::given(method("GET"))
.and(path("/api/v1/indexer"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
@@ -374,7 +376,7 @@ mod tests {
.and(path(format!("/{id}/api")))
.and(query_param("t", "search"))
.and(query_param_is_missing("q"))
.respond_with(ResponseTemplate::new(200).set_body_string(FEED))
.respond_with(ResponseTemplate::new(200).set_body_string(&feed))
.mount(&server)
.await;
}
+5 -3
View File
@@ -53,8 +53,8 @@ impl TvGrabAction {
) -> Self {
Self {
indexers: IndexerDirectory::new(prowlarr.clone()),
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
prowlarr,
grabber: Grabber::new(transmission, download_dir, seeding),
}
}
@@ -678,7 +678,7 @@ mod tests {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
use crate::grab::SeedingLimits;
use crate::grab::{test_downloads, SeedingLimits};
use super::*;
@@ -786,6 +786,8 @@ mod tests {
async fn prowlarr() -> MockServer {
let server = MockServer::start().await;
let feed = test_downloads::rewrite(TV_RSS, "https://tracker/", &server);
test_downloads::mount(&server).await;
Mock::given(method("GET"))
.and(path("/api/v1/indexer"))
.respond_with(
@@ -805,7 +807,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/7/api"))
.and(query_param("t", "search"))
.respond_with(ResponseTemplate::new(200).set_body_string(TV_RSS))
.respond_with(ResponseTemplate::new(200).set_body_string(feed))
.mount(&server)
.await;
server