can't be bothered writing a commit message

This commit is contained in:
Breadway 2026-07-16 22:22:53 +08:00
commit 697b009627
55 changed files with 21320 additions and 0 deletions

View file

@ -0,0 +1,83 @@
pub mod rss;
pub mod scrape;
pub mod tpb;
use anyhow::Result;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq)]
pub struct RawReleaseItem {
pub title: String,
/// Magnet URI or direct .torrent download URL — qBittorrent's add-by-URL
/// endpoint accepts either identically.
pub link: String,
pub guid: String,
pub size_bytes: Option<u64>,
pub seeders: Option<u32>,
pub leechers: Option<u32>,
}
#[async_trait]
pub trait ReleaseSource {
/// Fetch current candidate releases. `query` is used by search-driven
/// sources (e.g. a scraped search page); feed-based sources ignore it
/// and return everything currently in the feed.
async fn fetch(&self, query: Option<&str>) -> Result<Vec<RawReleaseItem>>;
}
pub(crate) fn urlencode(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
c.to_string()
} else if c == ' ' {
"%20".to_string()
} else {
let mut buf = [0u8; 4];
c.encode_utf8(&mut buf)
.bytes()
.map(|b| format!("%{b:02X}"))
.collect()
}
})
.collect()
}
pub(crate) fn parse_human_size(s: &str) -> Option<u64> {
let s = s.trim();
let (num_part, unit) = s.split_once(' ')?;
let num: f64 = num_part.parse().ok()?;
let mult = match unit {
"B" => 1.0,
// 1337x labels these "KB/MB/GB" (decimal-looking) but the numbers
// are actually binary (1024-based), matching every other torrent
// site's convention — treat them the same as the KiB/MiB/GiB nyaa
// uses.
"KiB" | "KB" => 1024.0,
"MiB" | "MB" => 1024.0 * 1024.0,
"GiB" | "GB" => 1024.0 * 1024.0 * 1024.0,
"TiB" | "TB" => 1024.0_f64.powi(4),
_ => return None,
};
Some((num * mult) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_gib() {
assert_eq!(parse_human_size("38.1 GiB"), Some(40909563494));
}
#[test]
fn parses_mib() {
assert_eq!(parse_human_size("356.5 MiB"), Some(373817344));
}
#[test]
fn rejects_unknown_unit() {
assert_eq!(parse_human_size("5 XiB"), None);
}
}

View file

@ -0,0 +1,196 @@
use anyhow::Result;
use async_trait::async_trait;
use quick_xml::escape::unescape;
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use super::{parse_human_size, urlencode, RawReleaseItem, ReleaseSource};
/// RSS source for nyaa.si-style feeds, whose `nyaa:seeders`/`nyaa:leechers`/
/// `nyaa:size` custom-namespaced fields carry the seeder data this project
/// needs — generic feed libraries (e.g. feed-rs) don't surface arbitrary
/// vendor namespaces, so this parses the raw XML directly.
pub struct RssSource {
feed_url: String,
client: reqwest::Client,
}
impl RssSource {
pub fn new(feed_url: impl Into<String>) -> Self {
Self {
feed_url: feed_url.into(),
// The background loop holds the DB mutex across this fetch — no
// total timeout (reqwest's default) means a stalled connection
// hangs the whole daemon, not just this one request.
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client build"),
}
}
}
#[async_trait]
impl ReleaseSource for RssSource {
async fn fetch(&self, query: Option<&str>) -> Result<Vec<RawReleaseItem>> {
let url = build_search_url(&self.feed_url, query);
let bytes = self.client.get(&url).send().await?.bytes().await?;
parse_nyaa_rss(&bytes)
}
}
/// nyaa's search *is* its RSS feed with a `q` param appended — same
/// endpoint, same custom-namespace fields, so the parser needs no changes
/// at all for search-driven use.
fn build_search_url(feed_url: &str, query: Option<&str>) -> String {
match query {
Some(q) => {
let sep = if feed_url.contains('?') { '&' } else { '?' };
format!("{feed_url}{sep}q={}", urlencode(q))
}
None => feed_url.to_string(),
}
}
fn parse_nyaa_rss(bytes: &[u8]) -> Result<Vec<RawReleaseItem>> {
let mut reader = Reader::from_reader(bytes);
reader.config_mut().trim_text(true);
let mut items = Vec::new();
let mut buf = Vec::new();
let mut in_item = false;
let mut cur_tag = String::new();
let mut title = None;
let mut link = None;
let mut guid = None;
let mut seeders = None;
let mut leechers = None;
let mut size_bytes = None;
loop {
match reader.read_event_into(&mut buf)? {
Event::Eof => break,
Event::Start(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
if name == "item" {
in_item = true;
title = None;
link = None;
guid = None;
seeders = None;
leechers = None;
size_bytes = None;
}
cur_tag = name;
}
Event::Text(t) if in_item => {
let raw = t.decode()?;
let text = unescape(&raw)?.into_owned();
match cur_tag.as_str() {
"title" => title = Some(text),
"link" => link = Some(text),
"guid" => guid = Some(text),
"nyaa:seeders" => seeders = text.parse().ok(),
"nyaa:leechers" => leechers = text.parse().ok(),
"nyaa:size" => size_bytes = parse_human_size(&text),
_ => {}
}
}
Event::End(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
if name == "item" {
if let (Some(title), Some(link), Some(guid)) =
(title.take(), link.take(), guid.take())
{
items.push(RawReleaseItem {
title,
link,
guid,
size_bytes,
seeders,
leechers,
});
}
in_item = false;
}
}
_ => {}
}
buf.clear();
}
Ok(items)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:nyaa="https://nyaa.si/xmlns/nyaa" version="2.0">
<channel>
<title>Nyaa - Home - Torrent File RSS</title>
<item>
<title>[Group] Some Show - 05 [1080p]</title>
<link>https://nyaa.si/download/2130903.torrent</link>
<guid isPermaLink="true">https://nyaa.si/view/2130903</guid>
<pubDate>Sat, 11 Jul 2026 08:51:04 -0000</pubDate>
<nyaa:seeders>12</nyaa:seeders>
<nyaa:leechers>3</nyaa:leechers>
<nyaa:size>356.5 MiB</nyaa:size>
</item>
</channel>
</rss>"#;
#[test]
fn parses_nyaa_item_with_custom_fields() {
let items = parse_nyaa_rss(SAMPLE.as_bytes()).unwrap();
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.title, "[Group] Some Show - 05 [1080p]");
assert_eq!(item.link, "https://nyaa.si/download/2130903.torrent");
assert_eq!(item.guid, "https://nyaa.si/view/2130903");
assert_eq!(item.seeders, Some(12));
assert_eq!(item.leechers, Some(3));
assert_eq!(item.size_bytes, Some(373817344));
}
#[test]
fn build_search_url_appends_query_param() {
assert_eq!(
build_search_url("https://nyaa.si/?page=rss&c=1_2", Some("Some Movie 2016")),
"https://nyaa.si/?page=rss&c=1_2&q=Some%20Movie%202016"
);
}
#[test]
fn build_search_url_handles_a_feed_url_with_no_existing_query_string() {
assert_eq!(
build_search_url("https://nyaa.si/rss", Some("Some Movie")),
"https://nyaa.si/rss?q=Some%20Movie"
);
}
#[test]
fn build_search_url_is_unchanged_without_a_query() {
assert_eq!(
build_search_url("https://nyaa.si/?page=rss", None),
"https://nyaa.si/?page=rss"
);
}
/// Hits the real nyaa.si feed — not run by default. `cargo test -- --ignored`
/// to sanity-check the parser against live markup if nyaa changes their format.
#[tokio::test]
#[ignore]
async fn parses_live_nyaa_feed() {
let source = RssSource::new("https://nyaa.si/?page=rss");
let items = source.fetch(None).await.unwrap();
assert!(
!items.is_empty(),
"expected at least one item from live feed"
);
assert!(items.iter().any(|i| i.seeders.is_some()));
}
}

View file

@ -0,0 +1,549 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use scraper::{Html, Selector};
use super::{parse_human_size, urlencode, RawReleaseItem, ReleaseSource};
const BASE_COOLDOWN_SECS: u64 = 5 * 60;
const MAX_COOLDOWN: Duration = Duration::from_secs(6 * 60 * 60);
const RATE_LIMIT_MIN_COOLDOWN: Duration = Duration::from_secs(30 * 60);
const RATE_LIMIT_MAX_RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
/// Hard cap on the tracked failure streak — well past the point where
/// `BASE_COOLDOWN_SECS * 4^(streak-1)` already exceeds `MAX_COOLDOWN`, it
/// exists purely so the exponent can never grow large enough to overflow.
const MAX_FAILURE_STREAK: u32 = 10;
/// 1337x's main domain (1337x.to) bans IPs at the Cloudflare WAF level
/// after bursts of automated traffic — a network-level block that no
/// amount of browser-fingerprint evasion gets around. Its community
/// mirrors run on separate domains/Cloudflare zones, so a ban on one
/// doesn't carry over. Requests are round-robined across mirrors (not just
/// tried in a fixed fallback order) so no single domain absorbs the bulk of
/// traffic, and a mirror that fails is demoted into a cooldown rather than
/// re-probed on the very next search.
pub struct ScrapeSource {
mirrors: Vec<String>,
client: reqwest::Client,
ring: Mutex<MirrorRing>,
}
struct MirrorRing {
next: usize,
cooldown_until: Vec<Option<Instant>>,
failure_streak: Vec<u32>,
}
impl MirrorRing {
fn new(count: usize) -> Self {
Self {
next: 0,
cooldown_until: vec![None; count],
failure_streak: vec![0; count],
}
}
}
/// Distinguishes *why* a mirror attempt failed, since the right cooldown
/// differs: a WAF-issued rate-limit/block response means "this domain is
/// now watching you" and gets a firm minimum cooldown regardless of streak,
/// while a timeout or a challenge page gets the milder exponential ladder.
#[derive(Debug)]
enum MirrorError {
/// No results table found at all — most likely a Cloudflare challenge
/// page served with an HTTP 200 (so `error_for_status` wouldn't catch
/// it), or the mirror's HTML layout has drifted.
Challenge,
/// 403/429/503 — an explicit throttle/block signal from the WAF, not a
/// generic network failure.
RateLimited {
retry_after_secs: Option<u64>,
},
Other(anyhow::Error),
}
impl std::fmt::Display for MirrorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MirrorError::Challenge => write!(f, "no results table found (challenge page?)"),
MirrorError::RateLimited { retry_after_secs } => {
write!(f, "rate limited (retry_after={retry_after_secs:?})")
}
MirrorError::Other(e) => write!(f, "{e}"),
}
}
}
/// Returned by [`ScrapeSource::fetch`] when every mirror is either on
/// cooldown or failed this attempt — a distinct type (rather than a plain
/// string error) so callers can `downcast_ref` to trigger cycle-level
/// backoff without string-matching an error message.
#[derive(Debug)]
pub struct AllMirrorsFailed;
impl std::fmt::Display for AllMirrorsFailed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "all 1337x mirrors failed or are in cooldown")
}
}
impl std::error::Error for AllMirrorsFailed {}
impl ScrapeSource {
pub fn new(mirrors: Vec<String>) -> Self {
let count = mirrors.len();
Self {
mirrors,
client: reqwest::Client::builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
.timeout(Duration::from_secs(30))
// Cloudflare's cf_clearance cookie is per-zone (per mirror
// domain) — holding it means a mirror that challenged once
// can pass on subsequent requests instead of re-challenging
// every single time.
.cookie_store(true)
.build()
.expect("reqwest client build"),
ring: Mutex::new(MirrorRing::new(count)),
}
}
/// Mirror indices to try, starting from the round-robin cursor and
/// wrapping, skipping any still in cooldown. Advances the cursor
/// unconditionally (not just on success) so consecutive searches keep
/// moving through the ring instead of retrying the same start point.
fn candidate_order(&self) -> Vec<usize> {
let mut ring = self.ring.lock().expect("mirror ring poisoned");
let start = ring.next;
if !self.mirrors.is_empty() {
ring.next = (ring.next + 1) % self.mirrors.len();
}
compute_candidate_order(
self.mirrors.len(),
start,
&ring.cooldown_until,
Instant::now(),
)
}
fn record_success(&self, idx: usize) {
let mut ring = self.ring.lock().expect("mirror ring poisoned");
ring.failure_streak[idx] = 0;
}
fn demote(&self, idx: usize, err: &MirrorError) {
let mut ring = self.ring.lock().expect("mirror ring poisoned");
let cooldown = match err {
MirrorError::RateLimited { retry_after_secs } => {
// A rate-limit response isn't generic flakiness — don't let
// it ratchet the exponential streak, just apply a firm
// minimum (or the server's own Retry-After, capped).
let retry_after = retry_after_secs
.map(|s| Duration::from_secs(s).min(RATE_LIMIT_MAX_RETRY_AFTER));
retry_after
.unwrap_or(RATE_LIMIT_MIN_COOLDOWN)
.max(RATE_LIMIT_MIN_COOLDOWN)
}
MirrorError::Challenge | MirrorError::Other(_) => {
let streak = (ring.failure_streak[idx] + 1).min(MAX_FAILURE_STREAK);
ring.failure_streak[idx] = streak;
let secs = BASE_COOLDOWN_SECS.saturating_mul(4u64.saturating_pow(streak - 1));
Duration::from_secs(secs).min(MAX_COOLDOWN)
}
};
ring.cooldown_until[idx] = Some(Instant::now() + cooldown);
}
async fn search_mirror(
&self,
mirror: &str,
query: &str,
) -> std::result::Result<Vec<RawReleaseItem>, MirrorError> {
let url = format!(
"{}/search/{}/1/",
mirror.trim_end_matches('/'),
urlencode(query)
);
let resp = self.client.get(&url).send().await.map_err(|e| {
MirrorError::Other(anyhow::Error::new(e).context(format!("request to {url} failed")))
})?;
let status = resp.status();
if matches!(status.as_u16(), 403 | 429 | 503) {
let retry_after_secs = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
return Err(MirrorError::RateLimited { retry_after_secs });
}
let resp = resp.error_for_status().map_err(|e| {
MirrorError::Other(
anyhow::Error::new(e).context(format!("{url} returned an error status")),
)
})?;
let body = resp.text().await.map_err(|e| {
MirrorError::Other(anyhow::Error::new(e).context("failed to read response body"))
})?;
parse_search_results(&body, mirror).ok_or(MirrorError::Challenge)
}
}
/// Mirror indices to try, in round-robin order starting at `start`,
/// excluding any whose cooldown hasn't elapsed yet. Pure and separately
/// testable from the ring's locking/mutation.
fn compute_candidate_order(
len: usize,
start: usize,
cooldown_until: &[Option<Instant>],
now: Instant,
) -> Vec<usize> {
if len == 0 {
return Vec::new();
}
(0..len)
.map(|offset| (start + offset) % len)
.filter(|&i| cooldown_until[i].is_none_or(|until| now >= until))
.collect()
}
/// True if `link` is already directly usable by qBittorrent's add-by-URL
/// endpoint (a magnet URI or a direct .torrent download link) — false for
/// a detail-page URL that needs resolving first (what search results give
/// us, since the results table doesn't carry the magnet directly).
pub fn needs_resolution(link: &str) -> bool {
!(link.starts_with("magnet:") || link.ends_with(".torrent"))
}
/// The search results table only links to a torrent's detail page, not its
/// magnet URI directly — fetches that page and extracts the magnet link.
/// A free function (not tied to a `ScrapeSource`/mirror list) because the
/// detail URL captured at search time already has the right mirror domain
/// baked in, and this needs to be callable from the grab path generically
/// (including review-queue approval, which only has a stored link, not a
/// `ScrapeSource` instance) — called only for the one candidate actually
/// being grabbed, not for every search result, to keep request volume low.
pub async fn resolve_magnet(client: &reqwest::Client, detail_url: &str) -> Result<String> {
let resp = client
.get(detail_url)
.send()
.await
.with_context(|| format!("request to {detail_url} failed"))?
.error_for_status()
.with_context(|| format!("{detail_url} returned an error status"))?;
let body = resp.text().await.context("failed to read response body")?;
extract_magnet(&body).with_context(|| format!("no magnet link found on {detail_url}"))
}
#[async_trait]
impl ReleaseSource for ScrapeSource {
async fn fetch(&self, query: Option<&str>) -> Result<Vec<RawReleaseItem>> {
let Some(query) = query else {
bail!(
"ScrapeSource requires a search query (this is a search-driven source, not a feed)"
);
};
if self.mirrors.is_empty() {
bail!("no 1337x mirrors configured");
}
for idx in self.candidate_order() {
let mirror = &self.mirrors[idx];
match self.search_mirror(mirror, query).await {
Ok(items) => {
self.record_success(idx);
return Ok(items);
}
Err(e) => {
tracing::warn!(mirror, error = %e, "1337x mirror failed, trying next");
self.demote(idx, &e);
}
}
}
Err(anyhow::Error::new(AllMirrorsFailed))
}
}
/// Pulls the numeric torrent ID out of a 1337x listing/detail href of the
/// shape `/torrent/<id>/<slug>/` (relative) or `https://<mirror>/torrent/
/// <id>/<slug>/` (absolute) — the one part of the URL that's identical
/// across every mirror, unlike the domain. `None` if the href doesn't
/// contain `/torrent/` at all (an unexpected HTML shape), in which case
/// the caller falls back to the full URL rather than losing the item.
fn extract_torrent_id(href: &str) -> Option<&str> {
href.split("/torrent/")
.nth(1)?
.split('/')
.next()
.filter(|s| !s.is_empty())
}
/// `None` when the results table itself is missing from the document —
/// most likely a Cloudflare challenge page served with a 200 status (so it
/// never trips `error_for_status`), or the mirror's HTML layout drifted.
/// `Some(vec![])` is a genuine, trustworthy "no results" — those two cases
/// must not be conflated, since the former should demote the mirror and
/// the latter should not.
fn parse_search_results(html: &str, mirror: &str) -> Option<Vec<RawReleaseItem>> {
let doc = Html::parse_document(html);
let table_sel = Selector::parse("table.table-list").unwrap();
doc.select(&table_sel).next()?;
let row_sel = Selector::parse("table.table-list tbody tr").unwrap();
let name_link_sel = Selector::parse("td.coll-1.name a:not(.icon)").unwrap();
let seeds_sel = Selector::parse("td.coll-2").unwrap();
let leeches_sel = Selector::parse("td.coll-3").unwrap();
let size_sel = Selector::parse("td.coll-4").unwrap();
let mirror = mirror.trim_end_matches('/');
let mut items = Vec::new();
for row in doc.select(&row_sel) {
let Some(link_el) = row.select(&name_link_sel).next() else {
continue;
};
let Some(href) = link_el.value().attr("href") else {
continue;
};
let title: String = link_el.text().collect();
let title = title.trim().to_string();
if title.is_empty() {
continue;
}
let detail_url = if href.starts_with("http") {
href.to_string()
} else {
format!("{mirror}{href}")
};
// The dedup key must not depend on which mirror answered: the same
// physical torrent's `href` path (`/torrent/<id>/<slug>/`) is
// identical across every mirror, but `detail_url` bakes in
// whichever mirror happened to serve this particular search — with
// ~10 mirrors round-robined for ban resilience, using the full URL
// as `guid` meant the same release looked "new" up to 10 times
// over, defeating `is_seen`/`mark_seen` dedup entirely and flooding
// the review queue with duplicates of the same low-confidence
// match (verified live: one title queued 13 times). The numeric
// torrent ID is the stable, mirror-invariant identity instead.
let guid = extract_torrent_id(href)
.map(|id| format!("1337x:{id}"))
.unwrap_or_else(|| detail_url.clone());
let seeders = row
.select(&seeds_sel)
.next()
.and_then(|e| e.text().collect::<String>().trim().parse().ok());
let leechers = row
.select(&leeches_sel)
.next()
.and_then(|e| e.text().collect::<String>().trim().parse().ok());
let size_bytes = row
.select(&size_sel)
.next()
.and_then(|e| parse_human_size(e.text().collect::<String>().trim()));
items.push(RawReleaseItem {
title,
// Not directly grabbable yet — a detail-page URL, resolved to
// a real magnet link via `resolve_magnet` right before the
// winning candidate is actually sent to qBittorrent.
link: detail_url,
guid,
size_bytes,
seeders,
leechers,
});
}
Some(items)
}
fn extract_magnet(html: &str) -> Option<String> {
let doc = Html::parse_document(html);
let sel = Selector::parse(r#"a[href^="magnet:"]"#).unwrap();
doc.select(&sel)
.next()
.and_then(|e| e.value().attr("href"))
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_ROW: &str = r#"
<table class="table-list table table-responsive table-striped">
<thead><tr><th class="coll-1 name">name</th><th class="coll-2">se</th><th class="coll-3">le</th><th class="coll-date">time</th><th class="coll-4"><span class="size">size</span></th><th class="coll-5">uploader</th></tr></thead>
<tbody>
<tr>
<td class="coll-1 name"><a href="/sub/tv/HD/1/" class="icon"><i class="flaticon-hd"></i></a><a href="/torrent/3250239/The-Big-Bang-Theory-S12E01-720p-HDTV-x264-KILLERS-eztv/">The Big Bang Theory S12E01 720p HDTV x264-KILLERS [eztv]</a></td>
<td class="coll-2 seeds">3978</td>
<td class="coll-3 leeches">1443</td>
<td class="coll-date">Sep. 25th '18</td>
<td class="coll-4 size mob-uploader">559 MB</td>
<td class="coll-5 uploader"><a href="/user/EZTVag/">EZTVag</a></td>
</tr>
</tbody>
</table>"#;
#[test]
fn parses_a_real_captured_result_row() {
let items = parse_search_results(SAMPLE_ROW, "https://13377x.info").unwrap();
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(
item.title,
"The Big Bang Theory S12E01 720p HDTV x264-KILLERS [eztv]"
);
assert_eq!(
item.link,
"https://13377x.info/torrent/3250239/The-Big-Bang-Theory-S12E01-720p-HDTV-x264-KILLERS-eztv/"
);
assert_eq!(item.guid, "1337x:3250239");
assert_eq!(item.seeders, Some(3978));
assert_eq!(item.leechers, Some(1443));
assert_eq!(item.size_bytes, Some(586153984));
}
#[test]
fn guid_is_identical_across_different_mirrors_for_the_same_torrent() {
let a = parse_search_results(SAMPLE_ROW, "https://13377x.info").unwrap();
let b = parse_search_results(SAMPLE_ROW, "https://1337x.maskbay.info").unwrap();
// The `link` legitimately differs (it's used to actually fetch from
// whichever mirror answered this search) but the dedup `guid` must
// not, or mirror rotation defeats `is_seen`/`mark_seen` entirely —
// this was a real bug that flooded the review queue with up to 13
// duplicate entries for the same release.
assert_ne!(a[0].link, b[0].link);
assert_eq!(a[0].guid, b[0].guid);
}
#[test]
fn extract_torrent_id_handles_relative_and_absolute_hrefs() {
assert_eq!(
extract_torrent_id("/torrent/3602010/Instant-Family-2018/"),
Some("3602010")
);
assert_eq!(
extract_torrent_id("https://13377x.info/torrent/3602010/Instant-Family-2018/"),
Some("3602010")
);
}
#[test]
fn extract_torrent_id_is_none_for_an_unexpected_shape() {
assert_eq!(extract_torrent_id("/sub/tv/HD/1/"), None);
}
#[test]
fn returns_none_when_results_table_is_absent() {
let challenge_page = "<html><body><h1>Just a moment...</h1></body></html>";
assert_eq!(
parse_search_results(challenge_page, "https://13377x.info"),
None
);
}
#[test]
fn returns_empty_vec_for_a_genuine_no_results_page() {
let empty_results = r#"<table class="table-list table table-responsive table-striped">
<thead><tr><th class="coll-1 name">name</th></tr></thead>
<tbody></tbody>
</table>"#;
assert_eq!(
parse_search_results(empty_results, "https://13377x.info"),
Some(vec![])
);
}
#[test]
fn extracts_magnet_from_detail_page() {
let html = r#"<a href="magnet:?xt=urn:btih:F30F455DC4C64A38E18C79C853B2A80B417C2343&dn=test">Magnet Download</a>"#;
assert_eq!(
extract_magnet(html).as_deref(),
Some("magnet:?xt=urn:btih:F30F455DC4C64A38E18C79C853B2A80B417C2343&dn=test")
);
}
#[test]
fn urlencode_handles_spaces_and_unicode() {
assert_eq!(urlencode("Big Buck Bunny"), "Big%20Buck%20Bunny");
}
#[test]
fn candidate_order_round_robins_from_start() {
let cooldowns = vec![None, None, None, None];
let order = compute_candidate_order(4, 2, &cooldowns, Instant::now());
assert_eq!(order, vec![2, 3, 0, 1]);
}
#[test]
fn candidate_order_skips_mirrors_still_in_cooldown() {
let now = Instant::now();
let cooldowns = vec![
None,
Some(now + Duration::from_secs(600)), // still cooling down
None,
Some(now - Duration::from_secs(1)), // cooldown already elapsed
];
let order = compute_candidate_order(4, 0, &cooldowns, now);
assert_eq!(order, vec![0, 2, 3]);
}
#[test]
fn candidate_order_empty_when_every_mirror_cooling_down() {
let now = Instant::now();
let cooldowns = vec![Some(now + Duration::from_secs(60)); 3];
let order = compute_candidate_order(3, 0, &cooldowns, now);
assert!(order.is_empty());
}
#[test]
fn demote_applies_a_firm_minimum_cooldown_for_rate_limiting() {
let source = ScrapeSource::new(vec!["https://a".into(), "https://b".into()]);
source.demote(
0,
&MirrorError::RateLimited {
retry_after_secs: None,
},
);
let ring = source.ring.lock().unwrap();
let until = ring.cooldown_until[0].expect("should be cooling down");
assert!(until >= Instant::now() + Duration::from_secs(29 * 60));
}
#[test]
fn demote_caps_rate_limit_retry_after_at_one_hour() {
let source = ScrapeSource::new(vec!["https://a".into()]);
source.demote(
0,
&MirrorError::RateLimited {
retry_after_secs: Some(999_999),
},
);
let ring = source.ring.lock().unwrap();
let until = ring.cooldown_until[0].expect("should be cooling down");
assert!(until <= Instant::now() + RATE_LIMIT_MAX_RETRY_AFTER + Duration::from_secs(5));
}
#[test]
fn demote_escalates_generic_failures_exponentially() {
let source = ScrapeSource::new(vec!["https://a".into()]);
source.demote(0, &MirrorError::Challenge);
let first = source.ring.lock().unwrap().cooldown_until[0].unwrap();
source.demote(0, &MirrorError::Challenge);
let second = source.ring.lock().unwrap().cooldown_until[0].unwrap();
assert!(second > first);
}
#[test]
fn record_success_resets_failure_streak() {
let source = ScrapeSource::new(vec!["https://a".into()]);
source.demote(0, &MirrorError::Challenge);
assert_eq!(source.ring.lock().unwrap().failure_streak[0], 1);
source.record_success(0);
assert_eq!(source.ring.lock().unwrap().failure_streak[0], 0);
}
}

View file

@ -0,0 +1,132 @@
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Deserialize;
use super::{urlencode, RawReleaseItem, ReleaseSource};
/// A community-run JSON API mirror of The Pirate Bay's search — unlike
/// 1337x, this is a genuine machine-readable API (not HTML scraping), and
/// unlike 1337x's HTML results table, `info_hash` is enough to build a
/// magnet directly: no second detail-page fetch needed to resolve a link
/// before grabbing. Also proved dramatically more precise in practice —
/// its search actually ranks by relevance, where 1337x's is a pure
/// seeder-count sort that buries genuine matches for common-word titles
/// under unrelated, much-more-seeded content.
pub struct TpbSource {
api_url: String,
client: reqwest::Client,
}
#[derive(Deserialize)]
struct TpbResult {
id: String,
name: String,
info_hash: String,
seeders: String,
leechers: String,
size: String,
}
const TRACKERS: &[&str] = &[
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://exodus.desync.com:6969/announce",
];
fn build_magnet(info_hash: &str, name: &str) -> String {
let mut magnet = format!("magnet:?xt=urn:btih:{info_hash}&dn={}", urlencode(name));
for t in TRACKERS {
magnet.push_str("&tr=");
magnet.push_str(&urlencode(t));
}
magnet
}
impl TpbSource {
pub fn new(api_url: impl Into<String>) -> Self {
Self {
api_url: api_url.into(),
// No total timeout is reqwest's default — the background loop
// holds the DB mutex across this fetch, so a stalled connection
// would hang the whole daemon.
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client build"),
}
}
}
#[async_trait]
impl ReleaseSource for TpbSource {
async fn fetch(&self, query: Option<&str>) -> Result<Vec<RawReleaseItem>> {
let Some(query) = query else {
anyhow::bail!(
"TpbSource requires a search query (this is a search-driven source, not a feed)"
);
};
let url = format!("{}?q={}", self.api_url, urlencode(query));
let results: Vec<TpbResult> = self
.client
.get(&url)
.send()
.await
.with_context(|| format!("request to {url} failed"))?
.error_for_status()
.with_context(|| format!("{url} returned an error status"))?
.json()
.await
.context("failed to parse apibay response as JSON")?;
Ok(results
.into_iter()
// A query with no matches returns a single sentinel row
// (id="0", an all-zero info_hash) rather than an empty array —
// has to be filtered out explicitly or it'd be treated as one
// real (and completely bogus) result.
.filter(|r| r.id != "0" && !r.info_hash.chars().all(|c| c == '0'))
.map(|r| RawReleaseItem {
title: r.name.clone(),
link: build_magnet(&r.info_hash, &r.name),
guid: r.info_hash,
size_bytes: r.size.parse().ok(),
seeders: r.seeders.parse().ok(),
leechers: r.leechers.parse().ok(),
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_magnet_includes_hash_name_and_trackers() {
let magnet = build_magnet("ABC123", "Some.Show.S01E01");
assert!(magnet.starts_with("magnet:?xt=urn:btih:ABC123&dn=Some.Show.S01E01"));
assert!(magnet.contains("tracker.opentrackr.org"));
}
#[test]
fn parses_a_real_captured_response() {
let body = r#"[{"id":"51630137","name":"Modern Family S01 (1080p BluRay)","info_hash":"8F87C7C186172F17E35F4512BB1A3E93B614ADED","leechers":"309","seeders":"379","size":"5395577424","num_files":"28","username":"rjaa","added":"1629816694","status":"vip","category":"208","imdb":""}]"#;
let results: Vec<TpbResult> = serde_json::from_str(body).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Modern Family S01 (1080p BluRay)");
assert_eq!(results[0].seeders, "379");
}
#[test]
fn filters_out_the_no_results_sentinel() {
let body = r#"[{"id":"0","name":"No results returned","info_hash":"0000000000000000000000000000000000000000","leechers":"0","seeders":"0","size":"0","num_files":"0","username":"","added":"0","status":"","category":"0","imdb":""}]"#;
let results: Vec<TpbResult> = serde_json::from_str(body).unwrap();
let filtered: Vec<_> = results
.into_iter()
.filter(|r| r.id != "0" && !r.info_hash.chars().all(|c| c == '0'))
.collect();
assert!(filtered.is_empty());
}
}