Skip to content

Commit 7bfc2b9

Browse files
committed
Replace Byparr and Browserless with TRAWL for Cloudflare bypass.
TRAWL's FlareSolverr-compatible API now handles CF-protected indexers, ext.to AJAX, sport-video downloads, and Reddit RSS fallback. Remove the Browserless module, add TRAWL POST/binary fetch helpers, and point Docker Compose/K8s at a shared Redis instance (TRAWL uses DB 1).
1 parent 94cf976 commit 7bfc2b9

29 files changed

Lines changed: 501 additions & 770 deletions

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ Everything in this directory has been fully replaced by Rust equivalents. It is
8484
- **`mediafusion-api`** (Rust, Axum): HTTP server on port 8001. Handles Stremio addon manifest, catalog, stream, and user profile endpoints.
8585
- **`mediafusion-worker`** (Rust): Long-running background job processor. Scrapes Prowlarr, RSS feeds, Telegram, YouTube, etc. Runs scheduled jobs.
8686
- **PostgreSQL**: Primary data store. Migrations live in `backend/migrations/` as `NNNN_description.{up|down}.sql` and are applied automatically on startup via sqlx.
87-
- **Redis**: Session cache, rate limiting, stream cache, job queue coordination.
87+
- **Redis**: Session cache, rate limiting, stream cache, job queue coordination. TRAWL (when deployed) uses Redis database `1` on the same instance; MediaFusion uses database `0`.
88+
- **TRAWL** *(optional, Docker Compose)*: Browser pool for Cloudflare/JS challenge bypass. FlareSolverr-compatible API at `/v1`. Required for ext.to, sport-video, and CF-protected public indexers.
8889
- **Frontend** (`clients/frontend`): React 19 config UI. Built with Vite, TailwindCSS 4, Radix UI, React Query.
8990
- **Kodi addon** (`clients/kodi`): Python-based Kodi client.
9091

backend/src/config.rs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -286,14 +286,11 @@ pub struct AppConfig {
286286
/// this needs to be longer than the slowest scraper. Default: 120.
287287
pub request_timeout: u64,
288288

289-
// ── Browser automation (Browserless v2 + Byparr) ─────────────
290-
/// Browserless v2 base URL (e.g. `http://browserless:3000`).
291-
/// Used by spiders that need real Chrome execution to bypass JS bot challenges
292-
/// (e.g. adm.tools on sport-video.org.ua).
293-
pub browserless_url: Option<String>,
294-
/// Byparr (FlareSolverr-compatible) base URL. When set, Cloudflare-protected
295-
/// public indexers (1337x, TPB, etc.) are fetched via Byparr instead of plain HTTP.
296-
pub byparr_url: Option<String>,
289+
// ── Browser automation (TRAWL) ───────────────────────────────
290+
/// TRAWL (FlareSolverr-compatible) base URL. When set, Cloudflare-protected
291+
/// public indexers (1337x, TPB, ext.to, etc.) and browser-backed spider fetches
292+
/// are handled via TRAWL's browser pool and session cache.
293+
pub trawl_url: Option<String>,
297294
/// Local path to `scraper_config.json` (fallback when remote fetch fails).
298295
pub scraper_config_path: String,
299296
/// `local` reads only from disk; `remote` (default) fetches from GitHub with Redis cache.
@@ -762,10 +759,9 @@ impl AppConfig {
762759
.ok().and_then(|v| v.parse().ok()).unwrap_or(15),
763760
stream_raw_redis_cache_ttl: env("STREAM_RAW_REDIS_CACHE_TTL_SECONDS")
764761
.ok().and_then(|v| v.parse().ok()).unwrap_or(900),
765-
browserless_url: env("BROWSERLESS_URL").ok()
766-
.filter(|s| !s.is_empty())
767-
.map(|u| u.trim_end_matches('/').to_string()),
768-
byparr_url: env("BYPARR_URL").ok()
762+
trawl_url: env("TRAWL_URL")
763+
.ok()
764+
.or_else(|| env("BYPARR_URL").ok())
769765
.filter(|s| !s.is_empty())
770766
.map(|u| u.trim_end_matches('/').to_string()),
771767
scraper_config_path: env("SCRAPER_CONFIG_PATH")

backend/src/jobs/handlers/spiders/ext_to.rs

Lines changed: 42 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/// Scrapers for ext.to torrent site — 5 catalog variants.
22
///
3-
/// ext.to is behind Cloudflare protection. We use the byparr (FlareSolverr)
4-
/// endpoint to get past the challenge when configured.
3+
/// ext.to is behind Cloudflare protection. We use TRAWL (FlareSolverr-compatible)
4+
/// to get past the challenge when configured.
55
///
66
/// Scraping flow:
77
/// 1. Browse the site (profile pages or search queries) via the listing URL.
@@ -23,12 +23,9 @@
2323
/// otherwise `magnet`), `timestamp`, `hmac` (SHA256 of
2424
/// `torrent_id|timestamp|pageToken`, despite the field's name it's a
2525
/// plain hash, not an HMAC) and `sessid` (the csrfToken value).
26-
/// - This endpoint re-validates the CF clearance cookie against the
27-
/// User-Agent that earned it, so a bare `reqwest` POST always gets
28-
/// re-challenged even with the right cookies. We replay the cookie
29-
/// jar + UA harvested from byparr's detail-page fetch through a
30-
/// real browserless Chrome instance (`BROWSERLESS_URL`), which
31-
/// passes because it's an actual browser, not a bare HTTP client.
26+
/// - This endpoint re-validates the CF clearance cookie, so a bare
27+
/// `reqwest` POST gets re-challenged. TRAWL's browser session cache
28+
/// handles the same-origin AJAX POST instead.
3229
/// - Fallback: legacy inline `magnet:?...` in HTML.
3330
/// 3. Build `ScrapedStream` and write via `stream_convert::write_back_torrents`.
3431
use std::time::{SystemTime, UNIX_EPOCH};
@@ -46,8 +43,8 @@ use crate::{
4643
},
4744
parser,
4845
scrapers::{
49-
ScrapedStream, SearchMeta, StreamFile, browser,
50-
fetcher::{fetch_byparr, fetch_plain},
46+
ScrapedStream, SearchMeta, StreamFile,
47+
fetcher::{fetch_plain, fetch_trawl, fetch_trawl_bytes, fetch_trawl_post},
5148
media_resolve, stream_convert, torrent_metadata,
5249
},
5350
util::{rate_limit, retry},
@@ -206,15 +203,15 @@ async fn fetch_html(
206203
label: &str,
207204
url: &str,
208205
client: &reqwest::Client,
209-
byparr_url: &Option<String>,
206+
trawl_url: &Option<String>,
210207
) -> Option<String> {
211208
retry::with_retry(label, || {
212209
let url = url.to_string();
213210
let client = client.clone();
214-
let bp = byparr_url.clone();
211+
let bp = trawl_url.clone();
215212
async move {
216213
if let Some(bp_url) = &bp
217-
&& let Some(r) = fetch_byparr(&client, bp_url, &url).await
214+
&& let Some(r) = fetch_trawl(&client, bp_url, &url).await
218215
{
219216
return Ok(r.html);
220217
}
@@ -477,23 +474,19 @@ fn detail_has_torrent_download(html: &str) -> bool {
477474
async fn post_ext_to_ajax(
478475
label: &str,
479476
client: &reqwest::Client,
480-
browserless_url: &str,
477+
trawl_url: &str,
481478
detail_url: &str,
482479
ajax_url: &str,
483480
tid: u64,
484481
page_token: &str,
485482
csrf: &str,
486483
download_type: &str,
487-
detail_cookies: &[(String, String)],
488-
detail_user_agent: &str,
489484
) -> Option<String> {
490485
retry::with_retry(label, || {
491486
let client = client.clone();
492487
let ajax_url = ajax_url.to_string();
493488
let referer = detail_url.to_string();
494-
let bl_url = browserless_url.to_string();
495-
let detail_cookies = detail_cookies.to_vec();
496-
let detail_user_agent = detail_user_agent.to_string();
489+
let trawl_url = trawl_url.to_string();
497490
let pt = page_token.to_string();
498491
let csrf = csrf.to_string();
499492
let download_type = download_type.to_string();
@@ -507,17 +500,19 @@ async fn post_ext_to_ajax(
507500
urlencoding::encode(&csrf),
508501
);
509502

510-
browser::post_with_cookies_via_browser(
503+
fetch_trawl_post(
511504
&client,
512-
&bl_url,
513-
&referer,
505+
&trawl_url,
514506
&ajax_url,
515507
&form_data,
516-
&detail_cookies,
517-
&detail_user_agent,
508+
&[
509+
("Content-Type", "application/x-www-form-urlencoded"),
510+
("X-Requested-With", "XMLHttpRequest"),
511+
("Referer", &referer),
512+
],
518513
)
519514
.await
520-
.ok_or_else(|| "browserless request failed".to_string())
515+
.ok_or_else(|| "trawl POST request failed".to_string())
521516
}
522517
})
523518
.await
@@ -543,9 +538,8 @@ fn magnet_from_ajax_response(raw: &str) -> Option<String> {
543538

544539
async fn torrent_bytes_from_ajax_response(
545540
client: &reqwest::Client,
546-
browserless_url: &str,
541+
trawl_url: &str,
547542
base_url: &str,
548-
detail_url: &str,
549543
raw: &str,
550544
) -> Option<Vec<u8>> {
551545
if raw.as_bytes().first() == Some(&b'd') {
@@ -569,14 +563,14 @@ async fn torrent_bytes_from_ajax_response(
569563
format!("{base_url}/{}", url.trim_start_matches('/'))
570564
};
571565

572-
browser::fetch_torrent_via_browser(client, browserless_url, detail_url, &absolute)
573-
.await
574-
.or(torrent_metadata::download_torrent_bytes(
566+
fetch_trawl_bytes(client, trawl_url, &absolute).await.or(
567+
torrent_metadata::download_torrent_bytes(
575568
client,
576569
&absolute,
577570
std::time::Duration::from_secs(30),
578571
)
579-
.await)
572+
.await,
573+
)
580574
}
581575

582576
/// Fetch a detail page and extract magnet / torrent file plus HTML file rows.
@@ -585,16 +579,15 @@ async fn fetch_detail_download(
585579
base_url: &str,
586580
detail_url: &str,
587581
client: &reqwest::Client,
588-
byparr_url: &Option<String>,
589-
browserless_url: &Option<String>,
582+
trawl_url: &Option<String>,
590583
) -> Option<DetailDownload> {
591584
let detail_result = retry::with_retry(label, || {
592585
let url = detail_url.to_string();
593586
let client = client.clone();
594-
let bp = byparr_url.clone();
587+
let bp = trawl_url.clone();
595588
async move {
596589
if let Some(bp_url) = &bp
597-
&& let Some(r) = fetch_byparr(&client, bp_url, &url).await
590+
&& let Some(r) = fetch_trawl(&client, bp_url, &url).await
598591
{
599592
return Ok(r);
600593
}
@@ -605,8 +598,6 @@ async fn fetch_detail_download(
605598
})
606599
.await
607600
.ok()?;
608-
let detail_cookies = detail_result.cookies;
609-
let detail_user_agent = detail_result.user_agent;
610601
let detail_html = detail_result.html;
611602
let detail_uploader = extract_uploader_from_detail_html(&detail_html);
612603
let html_files = parse_html_file_list(&detail_html);
@@ -636,56 +627,34 @@ async fn fetch_detail_download(
636627

637628
if let Some(tid) = numeric_id {
638629
let ajax_url = format!("{base_url}/ajax/getTorrentMagnet.php");
639-
if browserless_url.is_none() {
630+
if trawl_url.is_none() {
640631
warn!(
641-
"{label}: BROWSERLESS_URL not configured — ext.to AJAX downloads \
642-
require replaying a CF-cleared cookie through a real browser."
632+
"{label}: TRAWL_URL not configured — ext.to AJAX downloads \
633+
require TRAWL's browser session cache."
643634
);
644635
}
645636

646-
if let Some(bl_url) = browserless_url.as_deref() {
637+
if let Some(trawl) = trawl_url.as_deref() {
647638
let prefer_torrent =
648639
detail_has_torrent_download(&detail_html) || !html_files.is_empty();
649640

650641
if prefer_torrent
651642
&& let Some(raw) = post_ext_to_ajax(
652-
label,
653-
client,
654-
bl_url,
655-
detail_url,
656-
&ajax_url,
657-
tid,
658-
&pt,
659-
&csrf,
660-
"torrent",
661-
&detail_cookies,
662-
&detail_user_agent,
643+
label, client, trawl, detail_url, &ajax_url, tid, &pt, &csrf, "torrent",
663644
)
664645
.await
665646
{
666647
debug!(label, ajax_response = %&raw[..raw.len().min(500)], "AJAX torrent response");
667-
torrent_bytes = torrent_bytes_from_ajax_response(
668-
client, bl_url, base_url, detail_url, &raw,
669-
)
670-
.await;
648+
torrent_bytes =
649+
torrent_bytes_from_ajax_response(client, trawl, base_url, &raw).await;
671650
if torrent_bytes.is_some() {
672651
debug!(label, "downloaded .torrent via AJAX");
673652
}
674653
}
675654

676655
if magnet.is_none()
677656
&& let Some(raw) = post_ext_to_ajax(
678-
label,
679-
client,
680-
bl_url,
681-
detail_url,
682-
&ajax_url,
683-
tid,
684-
&pt,
685-
&csrf,
686-
"magnet",
687-
&detail_cookies,
688-
&detail_user_agent,
657+
label, client, trawl, detail_url, &ajax_url, tid, &pt, &csrf, "magnet",
689658
)
690659
.await
691660
{
@@ -716,8 +685,7 @@ pub(crate) async fn scrape_ext_catalog(
716685
let domain = ext_to_domain(&root);
717686
let base_url = format!("https://{domain}");
718687
let client = &ctx.state.http;
719-
let byparr_url = ctx.state.config.byparr_url.clone();
720-
let browserless_url = ctx.state.config.browserless_url.clone();
688+
let trawl_url = ctx.state.config.trawl_url.clone();
721689
let pool = &ctx.state.pool;
722690
let rate_key = domain.clone();
723691

@@ -754,8 +722,7 @@ pub(crate) async fn scrape_ext_catalog(
754722
return Err(JobError::Cancelled);
755723
}
756724
rate_limit::wait(&rate_key, 1).await;
757-
let Some(html) = fetch_html(spec.source, &current_url, client, &byparr_url).await
758-
else {
725+
let Some(html) = fetch_html(spec.source, &current_url, client, &trawl_url).await else {
759726
break;
760727
};
761728
let next_url = find_next_page_url(&html, &base_url, &current_url, is_profile);
@@ -778,7 +745,7 @@ pub(crate) async fn scrape_ext_catalog(
778745

779746
rate_limit::wait(&rate_key, 1).await;
780747

781-
let html = match fetch_html(spec.source, &current_url, client, &byparr_url).await {
748+
let html = match fetch_html(spec.source, &current_url, client, &trawl_url).await {
782749
Some(h) => h,
783750
None => {
784751
warn!("{}: failed to fetch listing {current_url}", spec.source);
@@ -808,15 +775,9 @@ pub(crate) async fn scrape_ext_catalog(
808775

809776
info!("{}: scraping \"{}\" — {detail_url}", spec.source, title);
810777

811-
let download = fetch_detail_download(
812-
spec.source,
813-
&base_url,
814-
&detail_url,
815-
client,
816-
&byparr_url,
817-
&browserless_url,
818-
)
819-
.await;
778+
let download =
779+
fetch_detail_download(spec.source, &base_url, &detail_url, client, &trawl_url)
780+
.await;
820781

821782
let Some(download) = download else {
822783
warn!(

0 commit comments

Comments
 (0)