fix(dl): resolve download links in arr (#100)
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:
@@ -36,6 +36,7 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
|
||||
|
||||
+166
-31
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user