Hanlde jacket torrent url
This commit is contained in:
Generated
+15
-1
@@ -2593,6 +2593,17 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -2818,7 +2829,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -3217,9 +3228,12 @@ name = "torad"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"clap",
|
"clap",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"librqbit-core",
|
"librqbit-core",
|
||||||
|
"reqwest",
|
||||||
|
"sha1 0.10.7",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.103"
|
anyhow = "1.0.103"
|
||||||
|
async-trait = "0.1"
|
||||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||||
librqbit = { version = "8.1.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
librqbit = { version = "8.1.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||||
librqbit-core = { version = "5.0.0", default-features = false }
|
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"] }
|
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 = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "signal"] }
|
||||||
tokio-stream = { version = "0.1.18", features = ["net"] }
|
tokio-stream = { version = "0.1.18", features = ["net"] }
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use librqbit::Magnet;
|
|
||||||
|
|
||||||
pub fn info_hash(source: &str) -> Result<String> {
|
|
||||||
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::<Vec<u8>>(&bytes)
|
|
||||||
.context("failed to parse .torrent file")?;
|
|
||||||
Ok(parsed.meta.info_hash.as_string())
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
mod db;
|
mod db;
|
||||||
mod magnet;
|
mod source;
|
||||||
mod torrents;
|
mod torrents;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|||||||
@@ -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<Resolved> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u8>),
|
||||||
|
/// Server returned a 3xx whose `Location` is a `magnet:` URI.
|
||||||
|
Magnet(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn fetch(client: &reqwest::Client, source: &str) -> Result<HttpFetchOutcome> {
|
||||||
|
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 (`<!doctype`, `<html`, or any `<` tag opener). Jackett returns
|
||||||
|
/// HTML error pages with HTTP 200 on auth/path failures, so catching this
|
||||||
|
/// here yields a clearer error than letting bencode parsing fail.
|
||||||
|
fn looks_like_html(bytes: &[u8]) -> bool {
|
||||||
|
let prefix: &[u8] = &bytes[..bytes.len().min(200)];
|
||||||
|
let trimmed = prefix
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.skip_while(|b| b.is_ascii_whitespace())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let lower = trimmed
|
||||||
|
.iter()
|
||||||
|
.take_while(|b| b.is_ascii())
|
||||||
|
.map(|b| b.to_ascii_lowercase())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
lower.starts_with(b"<!doctype")
|
||||||
|
|| lower.starts_with(b"<html")
|
||||||
|
|| lower.starts_with(b"<head")
|
||||||
|
|| lower.starts_with(b"<body")
|
||||||
|
|| lower.starts_with(b"<!--")
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
//! Handler for generic HTTP(S) `.torrent` URLs that are not Jackett proxy
|
||||||
|
//! links (covered by [`super::jackett::JackettHandler`]). Same redirect +
|
||||||
|
//! tolerant-parse logic, just a broader match.
|
||||||
|
|
||||||
|
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};
|
||||||
|
|
||||||
|
pub(crate) struct HttpTorrentHandler {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpTorrentHandler {
|
||||||
|
pub(crate) fn new(client: reqwest::Client) -> 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<Resolved> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
//! <https://github.com/Jackett/Jackett/issues/16492>) — 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<String> {
|
||||||
|
// Fast path: spec-compliant torrent, librqbit's parser handles it.
|
||||||
|
if let Ok(parsed) = torrent_from_bytes_ext::<Vec<u8>>(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<std::ops::Range<usize>> {
|
||||||
|
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 (`<len>:<bytes>`). Returns `None` on
|
||||||
|
/// malformed input or unexpected end-of-buffer.
|
||||||
|
fn walk_value(bytes: &[u8], start: usize) -> Option<usize> {
|
||||||
|
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 `<digits>:` 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
//! - <https://github.com/Jackett/Jackett/issues/8252> (proxy-link semantics)
|
||||||
|
//! - <https://github.com/Jackett/Jackett/issues/16492> (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<Resolved> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Resolved> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Resolved>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches a source string to the first matching [`SourceHandler`].
|
||||||
|
pub(crate) struct SourceResolver {
|
||||||
|
handlers: Vec<Box<dyn SourceHandler>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceResolver {
|
||||||
|
pub(crate) fn new() -> Result<Self> {
|
||||||
|
// 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<Resolved> {
|
||||||
|
for handler in &self.handlers {
|
||||||
|
if handler.matches(source) {
|
||||||
|
return handler.resolve(source).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::bail!("no handler matched source: {source}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db::{self, TorrentRow, TorrentState};
|
use crate::db::{self, TorrentRow, TorrentState};
|
||||||
use crate::magnet;
|
use crate::source::SourceResolver;
|
||||||
|
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
const BYTES_PER_MIB: f64 = 1_048_576.0;
|
const BYTES_PER_MIB: f64 = 1_048_576.0;
|
||||||
@@ -34,6 +34,7 @@ pub struct TorrentManager {
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
session: Arc<Session>,
|
session: Arc<Session>,
|
||||||
default_output_dir: PathBuf,
|
default_output_dir: PathBuf,
|
||||||
|
source: SourceResolver,
|
||||||
tracked: Mutex<HashMap<Uuid, Arc<ManagedTorrent>>>,
|
tracked: Mutex<HashMap<Uuid, Arc<ManagedTorrent>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,21 +49,24 @@ impl TorrentManager {
|
|||||||
let session = Session::new(download_dir.clone())
|
let session = Session::new(download_dir.clone())
|
||||||
.await
|
.await
|
||||||
.context("failed to create librqbit session")?;
|
.context("failed to create librqbit session")?;
|
||||||
|
let source = SourceResolver::new().context("failed to build source resolver")?;
|
||||||
Ok(Arc::new(Self {
|
Ok(Arc::new(Self {
|
||||||
pool,
|
pool,
|
||||||
session,
|
session,
|
||||||
default_output_dir: download_dir,
|
default_output_dir: download_dir,
|
||||||
|
source,
|
||||||
tracked: Mutex::new(HashMap::new()),
|
tracked: Mutex::new(HashMap::new()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add(&self, magnet: &str, output_dir: Option<&str>) -> Result<TorrentRow> {
|
pub async fn add(&self, source: &str, output_dir: Option<&str>) -> Result<TorrentRow> {
|
||||||
let info_hash = magnet::info_hash(magnet)?;
|
let resolved = self.source.resolve(source).await?;
|
||||||
|
let stored_source = resolved.rewritten_source.as_deref().unwrap_or(source);
|
||||||
let output_path = output_dir
|
let output_path = output_dir
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| self.default_output_dir.display().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<Option<TorrentStatus>> {
|
pub async fn get_status(&self, id: Uuid) -> Result<Option<TorrentStatus>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user