diff --git a/Cargo.lock b/Cargo.lock index a271092..b1b724b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2593,6 +2593,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2818,7 +2829,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sha1", + "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", "thiserror 2.0.18", @@ -3217,9 +3228,12 @@ name = "torad" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "clap", "librqbit", "librqbit-core", + "reqwest", + "sha1 0.10.7", "sqlx", "tokio", "tokio-stream", diff --git a/crates/torad/Cargo.toml b/crates/torad/Cargo.toml index 6837f82..e84806b 100644 --- a/crates/torad/Cargo.toml +++ b/crates/torad/Cargo.toml @@ -5,9 +5,12 @@ edition = "2024" [dependencies] anyhow = "1.0.103" +async-trait = "0.1" clap = { version = "4.6.1", features = ["derive", "env"] } librqbit = { version = "8.1.1", default-features = false, features = ["rust-tls", "http-api-client"] } librqbit-core = { version = "5.0.0", default-features = false } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +sha1 = "0.10" sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls", "uuid"] } tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "signal"] } tokio-stream = { version = "0.1.18", features = ["net"] } diff --git a/crates/torad/src/magnet.rs b/crates/torad/src/magnet.rs deleted file mode 100644 index d2fe171..0000000 --- a/crates/torad/src/magnet.rs +++ /dev/null @@ -1,17 +0,0 @@ -use anyhow::{Context, Result}; -use librqbit::Magnet; - -pub fn info_hash(source: &str) -> Result { - if source.starts_with("magnet:") { - let parsed = Magnet::parse(source).context("failed to parse magnet link")?; - let id20 = parsed - .as_id20() - .context("magnet link has no v1 (BTIH) info hash")?; - return Ok(id20.as_string()); - } - let bytes = - std::fs::read(source).with_context(|| format!("failed to read torrent file: {source}"))?; - let parsed = librqbit::torrent_from_bytes_ext::>(&bytes) - .context("failed to parse .torrent file")?; - Ok(parsed.meta.info_hash.as_string()) -} diff --git a/crates/torad/src/main.rs b/crates/torad/src/main.rs index 1cdb761..21c01a3 100644 --- a/crates/torad/src/main.rs +++ b/crates/torad/src/main.rs @@ -1,5 +1,5 @@ mod db; -mod magnet; +mod source; mod torrents; use std::path::PathBuf; diff --git a/crates/torad/src/source/file.rs b/crates/torad/src/source/file.rs new file mode 100644 index 0000000..603095a --- /dev/null +++ b/crates/torad/src/source/file.rs @@ -0,0 +1,32 @@ +//! Handler for local filesystem paths. Reads the file and delegates to the +//! shared tolerant [`super::info_hash`] extractor. Used when clients pass a +//! path to a `.torrent` file on disk rather than a URL. + +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use super::info_hash; +use super::{Resolved, SourceHandler}; + +pub(crate) struct FileHandler; + +#[async_trait] +impl SourceHandler for FileHandler { + fn matches(&self, source: &str) -> bool { + !source.starts_with("magnet:") + && !source.starts_with("http://") + && !source.starts_with("https://") + } + + async fn resolve(&self, source: &str) -> Result { + let bytes = tokio::fs::read(source) + .await + .with_context(|| format!("failed to read torrent file: {source}"))?; + let info_hash = + info_hash::from_bytes(&bytes).context("failed to extract info_hash from file")?; + Ok(Resolved { + info_hash, + rewritten_source: None, + }) + } +} diff --git a/crates/torad/src/source/http_fetch.rs b/crates/torad/src/source/http_fetch.rs new file mode 100644 index 0000000..e4071c8 --- /dev/null +++ b/crates/torad/src/source/http_fetch.rs @@ -0,0 +1,120 @@ +//! Shared HTTP-fetch helper used by handlers that need to download `.torrent` +//! bytes or follow `Location` redirects manually. +//! +//! reqwest's default policy follows 3xx only for `http`/`https`. Jackett (and +//! some indexers) 302-redirect to `magnet:` URIs — those would error out under +//! reqwest's default policy. We disable auto-following entirely and inspect +//! `Location` ourselves so the caller can route to the magnet handler. + +use anyhow::{Context, Result, bail}; +use reqwest::StatusCode; +use reqwest::header::LOCATION; +use tracing::{debug, warn}; + +/// Maximum number of HTTP redirect hops before giving up. +const MAX_REDIRECTS: u8 = 5; + +/// Truncation limit for error-response bodies logged for diagnostics. +const ERROR_BODY_LOG_LIMIT: usize = 500; + +pub(super) enum HttpFetchOutcome { + /// Server returned 2xx with a body — assumed to be bencoded `.torrent` bytes. + Bytes(Vec), + /// Server returned a 3xx whose `Location` is a `magnet:` URI. + Magnet(String), +} + +pub(super) async fn fetch(client: &reqwest::Client, source: &str) -> Result { + let mut current = source.to_string(); + for hop in 0..MAX_REDIRECTS { + let resp = client + .get(¤t) + .send() + .await + .with_context(|| format!("HTTP GET failed for {current}"))?; + let status = resp.status(); + debug!(url = %current, status = %status, hop, "HTTP response"); + + if status.is_redirection() { + let loc = resp + .headers() + .get(LOCATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if loc.is_empty() { + bail!("redirect with empty Location header from {current}"); + } + if loc.starts_with("magnet:") { + debug!(magnet = %loc, "redirected to magnet"); + return Ok(HttpFetchOutcome::Magnet(loc.to_string())); + } + if loc.starts_with("http://") || loc.starts_with("https://") { + current = loc.to_string(); + continue; + } + bail!("unsupported redirect target from {current}: {loc}"); + } + + if !status.is_success() { + // Read the body so error responses are useful in logs. + let body = resp.text().await.unwrap_or_default(); + let preview: String = body.chars().take(ERROR_BODY_LOG_LIMIT).collect(); + warn!(url = %current, status = %status, body = %preview, "non-success HTTP status"); + bail!( + "HTTP {status} from {current}: {preview}", + status = status, + current = current, + preview = preview, + ); + } + + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let bytes = resp + .bytes() + .await + .with_context(|| format!("failed reading response body from {current}"))? + .to_vec(); + debug!(url = %current, content_type = %content_type, bytes = bytes.len(), "received body"); + // Detect obvious non-torrent responses so callers get a clear error + // instead of an opaque bencode-parse failure downstream. + if looks_like_html(&bytes) { + bail!( + "received HTML from {current} (likely a Jackett error page); first 200 bytes: {}", + String::from_utf8_lossy(&bytes[..bytes.len().min(200)]) + ); + } + if status == StatusCode::OK && bytes.is_empty() { + bail!("empty response body from {current}"); + } + return Ok(HttpFetchOutcome::Bytes(bytes)); + } + bail!("exceeded {MAX_REDIRECTS} redirects while resolving {source}"); +} + +/// Cheap heuristic: true if the first non-whitespace bytes look like an HTML +/// document (` bool { + let prefix: &[u8] = &bytes[..bytes.len().min(200)]; + let trimmed = prefix + .iter() + .copied() + .skip_while(|b| b.is_ascii_whitespace()) + .collect::>(); + let lower = trimmed + .iter() + .take_while(|b| b.is_ascii()) + .map(|b| b.to_ascii_lowercase()) + .collect::>(); + lower.starts_with(b" Self { + Self { client } + } +} + +#[async_trait] +impl SourceHandler for HttpTorrentHandler { + fn matches(&self, source: &str) -> bool { + source.starts_with("http://") || source.starts_with("https://") + } + + async fn resolve(&self, source: &str) -> Result { + let outcome = http_fetch::fetch(&self.client, source).await?; + match outcome { + HttpFetchOutcome::Magnet(magnet) => { + let parsed = Magnet::parse(&magnet) + .with_context(|| format!("failed to parse redirected magnet: {magnet}"))?; + let id20 = parsed + .as_id20() + .context("redirected magnet has no v1 (BTIH) info hash")?; + Ok(Resolved { + info_hash: id20.as_string(), + rewritten_source: Some(magnet), + }) + } + HttpFetchOutcome::Bytes(bytes) => { + let info_hash = info_hash::from_bytes(&bytes) + .context("failed to extract info_hash from .torrent body")?; + Ok(Resolved { + info_hash, + rewritten_source: None, + }) + } + } + } +} diff --git a/crates/torad/src/source/info_hash.rs b/crates/torad/src/source/info_hash.rs new file mode 100644 index 0000000..d8766b2 --- /dev/null +++ b/crates/torad/src/source/info_hash.rs @@ -0,0 +1,214 @@ +//! Tolerant extraction of the BTIH v1 info-hash from bencoded `.torrent` +//! bytes. +//! +//! Strategy: try librqbit's strict parser first (correct, well-tested). If it +//! rejects the bytes — which happens in practice because some indexers +//! (rutracker, filelist) emit non-spec bencode with duplicate dict keys (see +//! ) — fall back to a +//! byte-span SHA1 of the `info` value, which doesn't care about dict-key +//! validity at all. +//! +//! The info-hash *is* by definition the SHA1 of the bencoded `info` value's +//! byte range, so walking the value's byte boundaries (rather than fully +//! parsing it) yields the correct hash even for spec-violating files. + +use anyhow::{Context, Result}; +use librqbit::torrent_from_bytes_ext; +use sha1::{Digest, Sha1}; + +/// Extracts the v1 info-hash from `.torrent` bytes. +pub(super) fn from_bytes(bytes: &[u8]) -> Result { + // Fast path: spec-compliant torrent, librqbit's parser handles it. + if let Ok(parsed) = torrent_from_bytes_ext::>(bytes) { + return Ok(parsed.meta.info_hash.as_string()); + } + // Fallback: locate the `info` value's byte span and SHA1 it directly. + let span = locate_info_value(bytes) + .context("could not locate `info` value in .torrent bytes; not a valid torrent file")?; + let mut hasher = Sha1::new(); + hasher.update(&bytes[span]); + let digest = hasher.finalize(); + Ok(hex_encode(&digest)) +} + +/// Locates the byte range `[start, end)` of the bencoded value associated +/// with the top-level dict key `info`. Returns `None` if the bytes aren't a +/// `.torrent`-shaped dict containing that key. +/// +/// Walks the top-level dict properly (key → value → key → value…) instead of +/// substring-scanning for `4:info`, so an `info` substring appearing inside +/// some other value's payload (e.g. a filename) can't trigger a false match. +fn locate_info_value(bytes: &[u8]) -> Option> { + let mut i = 0; + // Top-level value must be a dict. + if *bytes.get(i)? != b'd' { + return None; + } + i += 1; + while i < bytes.len() { + let b = bytes[i]; + if b == b'e' { + // Dict ended without finding `info`. + return None; + } + // Dict keys are always byte strings. + if !b.is_ascii_digit() { + return None; + } + let (key_len, colon_at) = read_string_header(bytes, i)?; + let key_start = colon_at + 1; + let key_end = key_start.checked_add(key_len)?; + if key_end > bytes.len() { + return None; + } + let key = &bytes[key_start..key_end]; + i = key_end; + if key == b"info" { + let value_end = walk_value(bytes, i)?; + return Some(i..value_end); + } + // Skip past this value to reach the next key. + i = walk_value(bytes, i)?; + } + None +} + +/// Walks one bencoded value starting at `start` and returns the index just +/// past its last byte. Handles dicts (`d...e`), lists (`l...e`), integers +/// (`i...e`), and byte strings (`:`). Returns `None` on +/// malformed input or unexpected end-of-buffer. +fn walk_value(bytes: &[u8], start: usize) -> Option { + let mut i = start; + let first = *bytes.get(i)?; + match first { + b'd' | b'l' => { + // Container: track depth, walk until matching `e`. + i += 1; + let mut depth: u32 = 1; + while depth > 0 { + let b = *bytes.get(i)?; + match b { + b'd' | b'l' => { + depth += 1; + i += 1; + } + b'e' => { + depth -= 1; + i += 1; + } + b'0'..=b'9' => { + // Byte string: read length prefix up to `:`, then skip + // the length-prefixed payload. + let (end, colon_at) = read_string_header(bytes, i)?; + i = colon_at + 1; + let payload_end = i.checked_add(end)?; + if payload_end > bytes.len() { + return None; + } + i = payload_end; + } + b'i' => { + // Integer: skip to next `e`. + i += 1; + while i < bytes.len() && bytes[i] != b'e' { + i += 1; + } + if i >= bytes.len() { + return None; + } + i += 1; // consume `e` + } + _ => return None, + } + if i > bytes.len() + 1 { + return None; + } + } + Some(i) + } + b'i' => { + i += 1; + while i < bytes.len() && bytes[i] != b'e' { + i += 1; + } + if i >= bytes.len() { + return None; + } + Some(i + 1) + } + b'0'..=b'9' => { + let (len, colon_at) = read_string_header(bytes, i)?; + let payload_end = colon_at + 1 + len; + if payload_end > bytes.len() { + return None; + } + Some(payload_end) + } + _ => None, + } +} + +/// Parses a bencode string-length prefix `:` at position `i`. +/// Returns `(length, index_of_colon)`. +fn read_string_header(bytes: &[u8], i: usize) -> Option<(usize, usize)> { + let mut j = i; + while j < bytes.len() && bytes[j].is_ascii_digit() { + j += 1; + } + if j >= bytes.len() || bytes[j] != b':' { + return None; + } + let len: usize = std::str::from_utf8(&bytes[i..j]).ok()?.parse().ok()?; + // Sanity-cap to avoid overflow / huge allocations on garbage input. + if len > bytes.len() { + return None; + } + Some((len, j)) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Synthetic minimal torrent: `d4:infod6:lengthi1e4:name1:aee`. + /// info_hash is SHA1 of the bytes between `4:info` and the matching outer + /// `e`, i.e. SHA1("d6:lengthi1e4:name1:ae"). + #[test] + fn extracts_hash_from_minimal_torrent() { + let torrent = b"d4:infod6:lengthi1e4:name1:aee"; + let resolved = from_bytes(torrent).expect("must extract info_hash"); + // Manually compute expected hash for the same info span. + let info_span = locate_info_value(torrent).unwrap(); + let mut h = Sha1::new(); + h.update(&torrent[info_span.clone()]); + let expected = hex_encode(&h.finalize()); + assert_eq!(resolved, expected); + assert_eq!(resolved.len(), 40); + } + + #[test] + fn locate_finds_info_value() { + // `d8:announce9:example.x4:infod6:lengthi1e4:name1:aee` — a torrent + // with a non-info key before `info`, to verify the walker skips the + // preceding value correctly. + let torrent = b"d8:announce9:example.x4:infod6:lengthi1e4:name1:aee"; + let span = locate_info_value(torrent).expect("info value must be found"); + assert_eq!(&torrent[span.clone()], b"d6:lengthi1e4:name1:ae"); + } + + #[test] + fn rejects_garbage() { + let garbage = b"this is not a torrent"; + assert!(locate_info_value(garbage).is_none()); + } +} diff --git a/crates/torad/src/source/jackett.rs b/crates/torad/src/source/jackett.rs new file mode 100644 index 0000000..d023538 --- /dev/null +++ b/crates/torad/src/source/jackett.rs @@ -0,0 +1,72 @@ +//! Handler for Jackett's `/dl/{indexer}/?jackett_apikey=...&path=...` proxy URLs. +//! +//! Jackett's download endpoint is not a static file: it re-authenticates to +//! the upstream indexer, fetches the details page, and either returns the +//! `.torrent` bytes (HTTP 200) or 302-redirects to a `magnet:` URI when the +//! indexer has no file download. Some indexers (notably rutracker and +//! filelist) also produce non-spec bencode with duplicate dict keys, which +//! librqbit's strict parser rejects — so we tolerate that case via a +//! byte-span SHA1 fallback (see [`super::info_hash`]). +//! +//! See: +//! - (proxy-link semantics) +//! - (non-spec .torrent files) + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use librqbit::Magnet; + +use super::http_fetch::{self, HttpFetchOutcome}; +use super::info_hash; +use super::{Resolved, SourceHandler}; + +/// Matches any HTTP(S) URL whose query string carries `jackett_apikey=`, +/// which uniquely identifies a Jackett proxy download link regardless of the +/// host/port Jackett is deployed on. +const JACKETT_MARKER: &str = "jackett_apikey="; + +pub(crate) struct JackettHandler { + client: reqwest::Client, +} + +impl JackettHandler { + pub(crate) fn new(client: reqwest::Client) -> Self { + Self { client } + } +} + +#[async_trait] +impl SourceHandler for JackettHandler { + fn matches(&self, source: &str) -> bool { + (source.starts_with("http://") || source.starts_with("https://")) + && source.contains(JACKETT_MARKER) + } + + async fn resolve(&self, source: &str) -> Result { + let outcome = http_fetch::fetch(&self.client, source).await?; + match outcome { + HttpFetchOutcome::Magnet(magnet) => { + let parsed = Magnet::parse(&magnet) + .with_context(|| format!("failed to parse redirected magnet: {magnet}"))?; + let id20 = parsed + .as_id20() + .context("redirected magnet has no v1 (BTIH) info hash")?; + // Store the magnet instead of the original Jackett URL: the + // magnet is stable, doesn't depend on Jackett being reachable + // at poller time, and is what librqbit will actually use. + Ok(Resolved { + info_hash: id20.as_string(), + rewritten_source: Some(magnet), + }) + } + HttpFetchOutcome::Bytes(bytes) => { + let info_hash = info_hash::from_bytes(&bytes) + .context("failed to extract info_hash from Jackett .torrent body")?; + Ok(Resolved { + info_hash, + rewritten_source: None, + }) + } + } + } +} diff --git a/crates/torad/src/source/magnet.rs b/crates/torad/src/source/magnet.rs new file mode 100644 index 0000000..a9a9cdf --- /dev/null +++ b/crates/torad/src/source/magnet.rs @@ -0,0 +1,28 @@ +//! Handler for `magnet:` URIs. Extracts the BTIH v1 info-hash from the +//! `xt=urn:btih:` parameter; no network IO. + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use librqbit::Magnet; + +use super::{Resolved, SourceHandler}; + +pub(super) struct MagnetHandler; + +#[async_trait] +impl SourceHandler for MagnetHandler { + fn matches(&self, source: &str) -> bool { + source.starts_with("magnet:") + } + + async fn resolve(&self, source: &str) -> Result { + let parsed = Magnet::parse(source).context("failed to parse magnet link")?; + let id20 = parsed + .as_id20() + .context("magnet link has no v1 (BTIH) info hash")?; + Ok(Resolved { + info_hash: id20.as_string(), + rewritten_source: None, + }) + } +} diff --git a/crates/torad/src/source/mod.rs b/crates/torad/src/source/mod.rs new file mode 100644 index 0000000..7e3c8cc --- /dev/null +++ b/crates/torad/src/source/mod.rs @@ -0,0 +1,78 @@ +//! Torrent-source resolution. +//! +//! A "source" is the opaque string a client hands to `add` — it may be a +//! magnet URI, an HTTP(S) URL pointing at a `.torrent` file (Jackett's `/dl/` +//! proxy, a direct indexer download, etc.), or a local file path. +//! +//! Each kind of source is handled by a [`SourceHandler`] implementation. The +//! [`SourceResolver`] holds an ordered list of handlers and dispatches to the +//! first one that claims the source via [`SourceHandler::matches`]. +//! +//! Order matters: more-specific handlers (magnet, Jackett) must precede more +//! general ones (HTTP, file). + +use anyhow::{Context, Result}; +use async_trait::async_trait; + +pub(crate) mod file; +mod http_fetch; +pub(crate) mod http_torrent; +pub(crate) mod info_hash; +pub(crate) mod jackett; +pub(crate) mod magnet; + +/// Result of resolving a source string into an identifying info-hash. +/// +/// `rewritten_source`, when set, replaces the original source for downstream +/// storage. This lets a Jackett URL that 302-redirects to a magnet be stored +/// as the magnet — cheaper and more reliable for the poller to re-fetch later. +pub(crate) struct Resolved { + pub info_hash: String, + pub rewritten_source: Option, +} + +/// A handler for one kind of torrent source. +#[async_trait] +pub(crate) trait SourceHandler: Send + Sync { + /// Return `true` if this handler recognises the source string. + fn matches(&self, source: &str) -> bool; + + /// Extract the info-hash (and optionally rewrite the source) from the + /// given source string. Called only when [`matches`](Self::matches) + /// returned `true`. + async fn resolve(&self, source: &str) -> Result; +} + +/// Dispatches a source string to the first matching [`SourceHandler`]. +pub(crate) struct SourceResolver { + handlers: Vec>, +} + +impl SourceResolver { + pub(crate) fn new() -> Result { + // Manual redirect policy: Jackett 302-redirects to `magnet:` URIs that + // reqwest would refuse to follow; we want to inspect `Location` + // ourselves so the magnet handler can take over. + let http_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .context("failed to build HTTP client")?; + Ok(Self { + handlers: vec![ + Box::new(magnet::MagnetHandler), + Box::new(jackett::JackettHandler::new(http_client.clone())), + Box::new(http_torrent::HttpTorrentHandler::new(http_client)), + Box::new(file::FileHandler), + ], + }) + } + + pub(crate) async fn resolve(&self, source: &str) -> Result { + for handler in &self.handlers { + if handler.matches(source) { + return handler.resolve(source).await; + } + } + anyhow::bail!("no handler matched source: {source}") + } +} diff --git a/crates/torad/src/torrents.rs b/crates/torad/src/torrents.rs index f911c83..a89e6cd 100644 --- a/crates/torad/src/torrents.rs +++ b/crates/torad/src/torrents.rs @@ -19,7 +19,7 @@ use tracing::{error, info, warn}; use uuid::Uuid; use crate::db::{self, TorrentRow, TorrentState}; -use crate::magnet; +use crate::source::SourceResolver; const POLL_INTERVAL: Duration = Duration::from_secs(2); const BYTES_PER_MIB: f64 = 1_048_576.0; @@ -34,6 +34,7 @@ pub struct TorrentManager { pool: PgPool, session: Arc, default_output_dir: PathBuf, + source: SourceResolver, tracked: Mutex>>, } @@ -48,21 +49,24 @@ impl TorrentManager { let session = Session::new(download_dir.clone()) .await .context("failed to create librqbit session")?; + let source = SourceResolver::new().context("failed to build source resolver")?; Ok(Arc::new(Self { pool, session, default_output_dir: download_dir, + source, tracked: Mutex::new(HashMap::new()), })) } - pub async fn add(&self, magnet: &str, output_dir: Option<&str>) -> Result { - let info_hash = magnet::info_hash(magnet)?; + pub async fn add(&self, source: &str, output_dir: Option<&str>) -> Result { + let resolved = self.source.resolve(source).await?; + let stored_source = resolved.rewritten_source.as_deref().unwrap_or(source); let output_path = output_dir .filter(|s| !s.is_empty()) .map(str::to_string) .unwrap_or_else(|| self.default_output_dir.display().to_string()); - db::insert_pending(&self.pool, &info_hash, magnet, &output_path).await + db::insert_pending(&self.pool, &resolved.info_hash, stored_source, &output_path).await } pub async fn get_status(&self, id: Uuid) -> Result> {