//! Orchestration: per-channel pipeline, gofile/0807 loops, subscriptions, //! --assume-done — Rust port of the logic in `version-python/dlp.py`. //! //! Failure policy (the core requirement): a failure in one URL must never //! abort the run. Catch, record in state.json, continue. Cooldown pauses //! are the only permitted stall. use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; use anyhow::{Context as _, anyhow}; use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; use serde_json::Value; use sha2::{Digest, Sha256}; use crate::discord::{self, FoundUrl}; use crate::globalstore::GlobalStore; use crate::gofile::{GoFileClient, GoFileError}; use crate::manifest::{ManifestRow, append_manifest}; use crate::state::{FileEntry, State}; use crate::zero807::{self, Zero807Error}; /// Runtime options for a download pass (the subset of CLI flags the /// orchestrator needs). #[derive(Debug, Clone)] pub struct RunOpts { pub retries: u32, pub retry_delay: u64, pub cooldown_failures: u32, pub cooldown_seconds: u64, pub hash: bool, pub extract: bool, pub password: Option, pub limit: Option, pub shuffle: bool, pub only: Option, /// only URLs posted at/after this point (Rust-only extension: "5d", /// "12h", "2026-10-04", or an RFC 3339 timestamp) pub since: Option, pub dry_run: bool, pub fresh: bool, } impl Default for RunOpts { fn default() -> Self { RunOpts { retries: 3, retry_delay: 5, cooldown_failures: 3, cooldown_seconds: 60, hash: false, extract: false, password: None, limit: None, shuffle: false, only: None, since: None, dry_run: false, fresh: false, } } } /// Parse a `--since` value: relative ("5d", "12h", "30m") or absolute /// (date, naive datetime, or RFC 3339). Naive times are read as UTC. pub fn parse_since(s: &str) -> anyhow::Result> { let t = s.trim(); for (suffix, dur) in [ ("d", chrono::Duration::hours(24)), ("h", chrono::Duration::hours(1)), ("m", chrono::Duration::minutes(1)), ] { if let Some(num) = t.strip_suffix(suffix) { let n: i32 = num.parse().map_err(|_| anyhow!("bad --since value: {s}"))?; return Ok(Utc::now() - dur * n); } } if let Ok(dt) = DateTime::parse_from_rfc3339(t) { return Ok(dt.with_timezone(&Utc)); } if let Ok(dt) = NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S%.f") { return Ok(dt.and_utc()); } if let Ok(dt) = NaiveDateTime::parse_from_str(t, "%Y-%m-%d %H:%M:%S") { return Ok(dt.and_utc()); } if let Ok(d) = NaiveDate::parse_from_str(t, "%Y-%m-%d") { return Ok(d.and_hms_opt(0, 0, 0).unwrap().and_utc()); } anyhow::bail!("bad --since value: {s} (use e.g. 5d, 12h, 2026-10-04, or RFC 3339)") } /// Parse a message timestamp from the export; None -> keep-with-warning. pub fn parse_message_ts(ts: &str) -> Option> { if let Ok(dt) = DateTime::parse_from_rfc3339(ts) { return Some(dt.with_timezone(&Utc)); } if let Ok(dt) = NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S%.f") { return Some(dt.and_utc()); } if let Ok(d) = NaiveDate::parse_from_str(ts, "%Y-%m-%d") { return Some(d.and_hms_opt(0, 0, 0).unwrap().and_utc()); } None } /// sha256 of a file, streamed (python sha256_of). pub fn sha256_of(path: &Path) -> anyhow::Result { let mut file = fs::File::open(path).with_context(|| format!("opening {} for hashing", path.display()))?; let mut hasher = Sha256::new(); std::io::copy(&mut file, &mut hasher)?; Ok(hex::encode(hasher.finalize())) } /// Lines from a URL list file; skips blanks and # comments. pub fn read_url_lines(path: &Path) -> anyhow::Result> { let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; Ok(text .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) .map(str::to_string) .collect()) } /// Load a plain URL list (e.g. failed_urls.txt); no attribution available. pub fn load_urls_file(path: &Path) -> anyhow::Result> { let mut found: HashMap = HashMap::new(); for url in read_url_lines(path)? { let Some(host) = discord::classify(&url) else { log::warn!("Unsupported host in {}: {url}", path.display()); continue; }; found.entry(url.clone()).or_insert(FoundUrl { url, host, message_id: None, timestamp: None, author: None, encoded: false, }); } let mut list: Vec = found.into_values().collect(); list.sort_by(|a, b| a.url.cmp(&b.url)); Ok(list) } /// Record a successful download: state.mark(ok) + global store + manifest /// rows (python record_files). pub fn record_files( state: &mut State, gstore: Option<&mut GlobalStore>, url: &str, files: &[Value], source: &SourceInfo, manifest_path: &Path, ) -> anyhow::Result<()> { let entries: Vec = files .iter() .map(|f| FileEntry { path: f["path"].as_str().unwrap_or_default().to_string(), size: f["size"].as_u64(), extra: { let mut m = std::collections::BTreeMap::new(); if let Some(n) = f.get("name") { m.insert("name".to_string(), n.clone()); } if let Some(h) = f.get("sha256") { m.insert("sha256".to_string(), h.clone()); } m }, }) .collect(); state.mark(url, |r| { r.status = "ok".into(); r.files = entries.clone(); // python passes error=None which is *skipped* (never clears) })?; if let Some(gs) = gstore { gs.mark( url, Some(&source.channel), source.host.as_deref(), Some(files.len() as u64), None, )?; } for f in files { let row = ManifestRow { channel: source.channel.clone(), url: url.to_string(), host: source.host.clone(), message_id: source.message_id.clone(), timestamp: source.timestamp.clone(), author: source.author.clone(), path: f["path"].as_str().map(str::to_string), size: f["size"].as_u64(), extra: { let mut m = std::collections::BTreeMap::new(); if let Some(n) = f.get("name") { m.insert("name".to_string(), n.clone()); } if let Some(h) = f.get("sha256") { m.insert("sha256".to_string(), h.clone()); } m }, }; append_manifest(manifest_path, &row)?; } Ok(()) } /// Message-level attribution for one URL (python source_by_url values). #[derive(Debug, Clone, Default)] pub struct SourceInfo { pub channel: String, pub host: Option, pub message_id: Option, pub timestamp: Option, pub author: Option, pub manifest: PathBuf, } fn source_of(channel_id: &str, f: &FoundUrl, manifest: &Path) -> SourceInfo { SourceInfo { channel: channel_id.to_string(), host: Some(f.host.to_string()), message_id: f.message_id.clone().map(Value::String), timestamp: f.timestamp.clone(), author: f.author.clone(), manifest: manifest.to_path_buf(), } } /// Optional: unzip each downloaded archive next to itself (python /// extract_zips in dlp.py). pub fn extract_zips(files: &[Value]) { for f in files { let Some(path_str) = f["path"].as_str() else { continue; }; let path = PathBuf::from(path_str); if path.extension().map(|e| e.to_ascii_lowercase()) != Some("zip".into()) || !path.exists() { continue; } let target = path.with_extension(""); if target.exists() && fs::read_dir(&target) .map(|d| d.count() > 0) .unwrap_or(false) { log::info!("Skip extracting {} (target exists)", path.display()); continue; } log::info!("Extracting {} -> {}", path.display(), target.display()); if let Err(exc) = fs::create_dir_all(&target).and_then(|_| unzip_to(&path, &target)) { log::warn!("Extraction of {} failed: {exc}", path.display()); } } } fn unzip_to(zip_path: &Path, target: &Path) -> std::io::Result<()> { let file = fs::File::open(zip_path)?; let mut zf = zip::ZipArchive::new(file)?; for i in 0..zf.len() { let mut entry = zf.by_index(i)?; if entry.is_dir() { fs::create_dir_all(target.join(entry.name()))?; continue; } let out_path = target.join(entry.name()); if let Some(parent) = out_path.parent() { fs::create_dir_all(parent)?; } let mut out = fs::File::create(&out_path)?; std::io::copy(&mut entry, &mut out)?; } Ok(()) } pub fn format_size(n: u64) -> String { let mut size = n as f64; for unit in ["B", "KiB", "MiB", "GiB", "TiB"] { if size < 1024.0 || unit == "TiB" { return format!("{size:.1} {unit}"); } size /= 1024.0; } format!("{n} B") } pub fn write_failed_urls(out_dir: &Path, state: &State) -> anyhow::Result { let dest = out_dir.join("failed_urls.txt"); if let Some(parent) = dest.parent() { fs::create_dir_all(parent)?; } let failures = state.failures(); let text = if failures.is_empty() { String::new() } else { format!("{}\n", failures.join("\n")) }; fs::write(&dest, text)?; Ok(dest) } /// Record URLs as already downloaded (no files) so they are never fetched. /// /// Manual import step for the pre-dlp.py era: point it at an old URL list /// you know is fully downloaded. There is no verification - garbage in, /// skipped forever. pub fn mark_assume_done(path: &Path, gstore: &mut GlobalStore) -> anyhow::Result { if gstore.path.is_none() { anyhow::bail!("--assume-done needs the global store (drop --no-global)"); } let mut count = 0; for url in read_url_lines(path)? { gstore.mark( &url, None, discord::classify(&url), Some(0), Some("assumed done via --assume-done"), )?; count += 1; } log::info!( "Marked {count} URLs as done in the global store ({})", gstore .path .as_ref() .map(|p| p.display().to_string()) .unwrap_or_default() ); Ok(count) } /// state.is_done(url) or the global store says done (python is_done closure). fn is_done(state: &State, gstore: Option<&GlobalStore>, url: &str) -> bool { state.is_done(url) || gstore.is_some_and(|g| g.is_done(url)) } /// Torrent links are never downloaded (user policy): the channel hosts huge /// user-created torrents; this tool is for direct-download packs. A skipped /// torrent is recorded as done (with a note) so it never comes back. fn is_torrent_url(url: &str) -> bool { url.to_ascii_lowercase().ends_with(".torrent") } fn is_torrent_file(name: &str) -> bool { name.to_ascii_lowercase().ends_with(".torrent") } /// One channel's pipeline; returns the number of failures (python /// process_channel). pub fn process_channel( channel_id: &str, out_dir: &Path, opts: &RunOpts, token: Option<&str>, mut gstore: Option<&mut GlobalStore>, urls_file: Option<&Path>, force_export: bool, ) -> anyhow::Result { let mut state = State::load(out_dir.join("state.json")); let manifest = out_dir.join("manifest.jsonl"); let found = if let Some(urls_file) = urls_file { load_urls_file(urls_file)? } else { let token = token.ok_or_else(|| anyhow!("internal error: no token resolved"))?; let export_path = discord::export_channel(channel_id, out_dir, token, force_export || opts.fresh)?; discord::extract_urls(&export_path)? }; let found: Vec = found .into_iter() .filter(|f| { if is_torrent_url(&f.url) { log::info!("Skip torrent (never downloaded): {}", f.url); if let Err(e) = state.mark(&f.url, |r| { r.status = "ok".into(); r.extra .insert("note".into(), Value::from("torrent link skipped (policy)")); }) { log::error!("Could not record torrent skip for {}: {e}", f.url); } return false; } true }) .filter(|f| opts.only.as_ref().is_none_or(|o| o.as_str() == f.host)) .collect(); let found: Vec = if let Some(since) = &opts.since { let cutoff = parse_since(since)?; let before = found.len(); let kept: Vec = found .into_iter() .filter(|f| match &f.timestamp { Some(ts) => match parse_message_ts(ts) { Some(dt) => dt >= cutoff, None => { log::warn!("Unparseable timestamp {ts:?} for {} - keeping it", f.url); true } }, None => { // e.g. --urls-file lists carry no attribution log::warn!("No timestamp for {} - keeping it", f.url); true } }) .collect(); log::info!( "--since {}: kept {} of {} URLs (rest are older)", since, kept.len(), before ); kept } else { found }; let mut gofile_urls: Vec = Vec::new(); let mut z0807_urls: Vec = Vec::new(); for f in &found { match f.host { "gofile" => gofile_urls.push(f.clone()), "0807" => z0807_urls.push(f.clone()), // classify() already filtered, but be defensive _ => { log::warn!("Skipping unsupported URL: {}", f.url); state.mark(&f.url, |r| { r.status = "failed".into(); r.error = Some("unsupported host".into()); })?; } } } let mut source_by_url: HashMap = HashMap::new(); for f in &gofile_urls { source_by_url.insert(f.url.clone(), source_of(channel_id, f, &manifest)); } for f in &z0807_urls { source_by_url.insert(f.url.clone(), source_of(channel_id, f, &manifest)); } // --limit applies per host class to the *pending* URLs, so a test run // downloads exactly N new things per host (done ones don't use up the // quota). Python replaces the bucket with pending[:limit]. if let Some(limit) = opts.limit { for list in [&mut gofile_urls, &mut z0807_urls] { let mut pending: Vec = list .iter() .filter(|f| !is_done(&state, gstore.as_deref(), &f.url)) .cloned() .collect(); if opts.shuffle { fisher_yates(&mut pending); } pending.truncate(limit); *list = pending; } } let total = gofile_urls.len() + z0807_urls.len(); let pending_len = gofile_urls .iter() .chain(&z0807_urls) .filter(|f| !is_done(&state, gstore.as_deref(), &f.url)) .count(); log::info!( "{cid}: {n} URLs ({g} gofile, {z} 0807); {d} done, {p} to download", cid = channel_id, n = total, g = gofile_urls.len(), z = z0807_urls.len(), d = total - pending_len, p = pending_len, ); if opts.dry_run { let selected: std::collections::HashSet<&str> = gofile_urls .iter() .chain(&z0807_urls) .map(|f| f.url.as_str()) .collect(); for f in &found { let flag = if selected.contains(f.url.as_str()) { "download" } else if is_done(&state, gstore.as_deref(), &f.url) { "done" } else { "skip" // cut by --limit / --only }; println!("[{:<6}] [{:<8}] {}", f.host, flag, f.url); } return Ok(0); } if !gofile_urls.is_empty() { run_gofile( &gofile_urls, opts, out_dir, &mut state, &source_by_url, gstore.as_deref_mut(), &manifest, )?; } if !z0807_urls.is_empty() { run_0807( &z0807_urls, opts, out_dir, &mut state, &source_by_url, gstore.as_deref_mut(), &manifest, )?; } let failed_file = write_failed_urls(out_dir, &state)?; let ok = state.records.values().filter(|r| r.status == "ok").count(); let bad = state.failures(); let dead = state .records .values() .filter(|r| { r.error .as_deref() .map(|e| e.contains("dead link")) .unwrap_or(false) }) .count(); let total_files: u64 = state.records.values().map(|r| r.files.len() as u64).sum(); let total_size: u64 = state .records .values() .flat_map(|r| r.files.iter()) .map(|f| f.size.unwrap_or(0)) .sum(); log::info!( "{}: {} ok ({} files, {}) / {} failed ({} dead links) / {} seen", out_dir.display(), ok, total_files, format_size(total_size), bad.len(), dead, state.records.len(), ); if !bad.is_empty() { log::info!( "Failed URLs written to {} - re-run this tool or use \ dlphelper dlp --urls-file {} to retry them.", failed_file.display(), failed_file .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default() ); } Ok(bad.len() as u64) } /// Fisher-Yates over pending URL pointers (python random.shuffle). fn fisher_yates(items: &mut [T]) { use rand::Rng; let mut rng = rand::rng(); for i in (1..items.len()).rev() { let j = rng.random_range(..=i); items.swap(i, j); } } /// GoFile pipeline (python run_gofile): one guest account for the whole /// pass, per-URL retry loop with dead-link/password/empty no-retry branches, /// re-auth on auth errors, cooldowns on consecutive failures - the run never /// stops. pub fn run_gofile( urls: &[FoundUrl], opts: &RunOpts, out_dir: &Path, state: &mut State, source_by_url: &HashMap, mut gstore: Option<&mut GlobalStore>, manifest: &Path, ) -> anyhow::Result<()> { if !urls .iter() .any(|f| !is_done(state, gstore.as_deref(), &f.url)) { return Ok(()); // nothing pending: no guest account creation, no API calls } let mut client = GoFileClient::new(opts.password.as_deref()); if let Err(exc) = client.ensure_token() { log::error!("{exc} - marking all pending gofile links as failed."); for f in urls { if is_done(state, gstore.as_deref(), &f.url) { continue; } state.mark(&f.url, |r| { r.status = "failed".into(); r.error = Some(exc.to_string()); r.files = Vec::new(); })?; } return Ok(()); } let mut consecutive_failures: u32 = 0; for f in urls { if is_done(state, gstore.as_deref(), &f.url) { log::info!("Skip (already downloaded): {}", f.url); continue; } let source = source_by_url.get(&f.url).cloned().unwrap_or_default(); let mut resolved = false; for attempt in 1..=opts.retries { // One attempt: list, download all files, hash. GoFileError maps // onto the python exception handlers below; anything else is an // unexpected bug and is recorded as such. The immediately-invoked // closure ends the shared borrow of `client` before the handler // arms below may re-authenticate. let files = (|| -> anyhow::Result> { let files_spec = client.list_files(&f.url).map_err(anyhow::Error::new)?; if files_spec.is_empty() { return Err(anyhow::Error::new(GoFileError::Empty( "content exists but contains no files".into(), ))); } let mut files: Vec = Vec::new(); let mut skipped_torrents = 0usize; for (relpath, name, link) in files_spec { if is_torrent_file(&name) { skipped_torrents += 1; log::info!("Skip torrent file in content (policy): {name}"); continue; } let dest = out_dir .join("downloads") .join("gofile") .join(&relpath) .join(&name); let size = client .download_file(&link, &dest) .map_err(anyhow::Error::new)?; let mut entry = json_entry(&dest, size, &name); if opts.hash { let h = sha256_of(&dest)?; entry["sha256"] = Value::String(h); } files.push(entry); } if files.is_empty() && skipped_torrents > 0 { // content holds only torrents: nothing to fetch, ever log::info!( "OK {} (0 files, {} torrent(s) skipped by policy)", f.url, skipped_torrents ); return Ok(Vec::new()); } Ok(files) })(); match files { Ok(files) => { log::info!("OK {} ({} files)", f.url, files.len()); record_files( state, gstore.as_deref_mut(), &f.url, &files, &source, manifest, )?; if files.is_empty() { state.mark(&f.url, |r| { r.extra.insert( "note".into(), Value::from("only .torrent files - skipped"), ); })?; } if opts.extract { extract_zips(&files); } consecutive_failures = 0; resolved = true; break; } Err(err) => { let Some(ge) = err.downcast_ref::() else { // a bug must not stop the run log::error!("Unexpected error for {}: {err:#}", f.url); let msg = format!("unexpected: {err}"); state.mark(&f.url, |r| { r.error = Some(msg); r.files = Vec::new(); })?; if attempt < opts.retries { thread::sleep(Duration::from_secs(opts.retry_delay * attempt as u64)); } continue; }; match ge { GoFileError::PasswordRequired(msg) => { // Retrying without a password never helps log::error!("PASSWORD REQUIRED {}: {msg}", f.url); state.mark(&f.url, |r| { r.status = "failed".into(); r.error = Some(msg.clone()); r.files = Vec::new(); })?; resolved = true; break; } GoFileError::NotFound(msg) => { // Dead links never come back - no retry log::error!("DEAD LINK {}: {msg}", f.url); state.mark(&f.url, |r| { r.status = "failed".into(); r.error = Some(msg.clone()); r.files = Vec::new(); })?; resolved = true; break; } GoFileError::Empty(msg) => { // Emptied content is stable - no retry log::error!("EMPTY CONTENT {}: {msg}", f.url); state.mark(&f.url, |r| { r.status = "failed".into(); r.error = Some(msg.clone()); r.files = Vec::new(); })?; resolved = true; break; } GoFileError::AuthError(msg) => { // Token may have died mid-run (rate limit); re-auth log::warn!("Auth error for {}: {msg} - re-authenticating", f.url); state.mark(&f.url, |r| { r.error = Some(msg.clone()); r.files = Vec::new(); })?; client.reset(); if let Err(reauth) = client.ensure_token() { log::error!("Re-authentication failed: {reauth}"); } if attempt < opts.retries { thread::sleep(Duration::from_secs( opts.retry_delay * attempt as u64, )); } } GoFileError::Error(msg) => { log::warn!( "Attempt {}/{} failed for {}: {msg}", attempt, opts.retries, f.url ); state.mark(&f.url, |r| { r.error = Some(msg.clone()); r.files = Vec::new(); })?; if attempt < opts.retries { thread::sleep(Duration::from_secs( opts.retry_delay * attempt as u64, )); } } } } } } if !resolved { let err = state .records .get(&f.url) .and_then(|r| r.error.clone()) .unwrap_or_else(|| "unknown".into()); state.mark(&f.url, |r| { r.status = "failed".into(); r.error = Some(err); r.files = Vec::new(); })?; consecutive_failures += 1; log::error!("FAILED {} after {} attempts", f.url, opts.retries); if consecutive_failures >= opts.cooldown_failures { log::warn!( "{} failures in a row - cooling down {}s before continuing \ (rate limit?). The run does not stop.", consecutive_failures, opts.cooldown_seconds, ); thread::sleep(Duration::from_secs(opts.cooldown_seconds)); } } } Ok(()) } fn json_entry(dest: &Path, size: u64, name: &str) -> Value { serde_json::json!({ "path": dest.display().to_string(), "size": size, "name": name, }) } /// 0807 pipeline (python run_0807). pub fn run_0807( urls: &[FoundUrl], opts: &RunOpts, out_dir: &Path, state: &mut State, source_by_url: &HashMap, mut gstore: Option<&mut GlobalStore>, manifest: &Path, ) -> anyhow::Result<()> { if !urls .iter() .any(|f| !is_done(state, gstore.as_deref(), &f.url)) { return Ok(()); } let out_dir = out_dir.join("downloads").join("0807"); let mut consecutive_failures: u32 = 0; for f in urls { if is_done(state, gstore.as_deref(), &f.url) { log::info!("Skip (already downloaded): {}", f.url); continue; } let source = source_by_url.get(&f.url).cloned().unwrap_or_default(); let mut resolved = false; for attempt in 1..=opts.retries { let downloaded = zero807::download(&f.url, &out_dir) .map_err(anyhow::Error::new) .and_then(|(dest, size)| -> anyhow::Result<(Value, u64)> { let mut entry = json_entry( &dest, size, &dest .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(), ); if opts.hash { let h = sha256_of(&dest)?; entry["sha256"] = Value::String(h); } Ok((entry, size)) }); match downloaded { Ok((entry, size)) => { log::info!("OK {} ({})", f.url, format_size(size)); record_files( state, gstore.as_deref_mut(), &f.url, std::slice::from_ref(&entry), &source, manifest, )?; if opts.extract { extract_zips(std::slice::from_ref(&entry)); } consecutive_failures = 0; resolved = true; break; } Err(err) => { let Some(ze) = err.downcast_ref::() else { // a bug must not stop the run log::error!("Unexpected error for {}: {err:#}", f.url); let msg = format!("unexpected: {err}"); state.mark(&f.url, |r| { r.error = Some(msg); r.files = Vec::new(); })?; if attempt < opts.retries { thread::sleep(Duration::from_secs(opts.retry_delay * attempt as u64)); } continue; }; match ze { Zero807Error::NotFound(msg) => { // Dead links never come back - no retry log::error!("DEAD LINK {}: {msg}", f.url); state.mark(&f.url, |r| { r.status = "failed".into(); r.error = Some(msg.clone()); r.files = Vec::new(); })?; resolved = true; break; } Zero807Error::Error(msg) => { log::warn!( "Attempt {}/{} failed for {}: {msg}", attempt, opts.retries, f.url ); state.mark(&f.url, |r| { r.error = Some(msg.clone()); r.files = Vec::new(); })?; if attempt < opts.retries { thread::sleep(Duration::from_secs( opts.retry_delay * attempt as u64, )); } } } } } } if !resolved { let err = state .records .get(&f.url) .and_then(|r| r.error.clone()) .unwrap_or_else(|| "unknown".into()); state.mark(&f.url, |r| { r.status = "failed".into(); r.error = Some(err); r.files = Vec::new(); })?; consecutive_failures += 1; log::error!("FAILED {} after {} attempts", f.url, opts.retries); if consecutive_failures >= opts.cooldown_failures { log::warn!( "{} failures in a row - cooling down {}s before continuing \ (HTTP 403 storm?). The run does not stop.", consecutive_failures, opts.cooldown_seconds, ); thread::sleep(Duration::from_secs(opts.cooldown_seconds)); } } } Ok(()) } /// Load subscriptions.json: [{"name", "channel_id", "out_dir"}, ...]. pub fn load_subscriptions(path: &Path) -> anyhow::Result)>> { let data: Value = serde_json::from_str( &fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?, )?; let entries = match &data { Value::Object(o) => o .get("subscriptions") .cloned() .unwrap_or_else(|| Value::Array(vec![])), Value::Array(_) => data.clone(), _ => anyhow::bail!( "{}: expected a list or {{\"subscriptions\": [...]}}", path.display() ), }; let mut subs = Vec::new(); for entry in entries.as_array().context("subscriptions must be a list")? { let bad = || { anyhow!( "{}: each subscription needs 'channel_id' and 'out_dir' (got: {entry})", path.display() ) }; let channel_id = entry .get("channel_id") .and_then(Value::as_str) .ok_or_else(bad)?; let out_dir = entry .get("out_dir") .and_then(Value::as_str) .ok_or_else(bad)?; let name = entry .get("name") .and_then(Value::as_str) .map(str::to_string); subs.push((channel_id.to_string(), out_dir.to_string(), name)); } Ok(subs) } #[cfg(test)] mod tests { use super::*; #[test] fn format_size_matches_python() { // python format_size values assert_eq!(format_size(0), "0.0 B"); assert_eq!(format_size(512), "512.0 B"); assert_eq!(format_size(1024), "1.0 KiB"); assert_eq!(format_size(1536), "1.5 KiB"); assert_eq!(format_size(1024 * 1024), "1.0 MiB"); assert_eq!(format_size(1024_u64 * 1024 * 1024), "1.0 GiB"); assert_eq!(format_size(1024_u64 * 1024 * 1024 * 1024), "1.0 TiB"); } #[test] fn read_url_lines_skips_comments_and_blanks() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("urls.txt"); fs::write(&p, "\n# comment\nhttps://a \n\nhttps://b\n# x\n").unwrap(); let lines = read_url_lines(&p).unwrap(); assert_eq!(lines, vec!["https://a", "https://b"]); } #[test] fn load_urls_file_sorts_and_dedupes() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("urls.txt"); fs::write( &p, "https://gofile.io/d/b\nhttps://0807.st/d/x\nhttps://gofile.io/d/a\n", ) .unwrap(); let found = load_urls_file(&p).unwrap(); let urls: Vec<&str> = found.iter().map(|f| f.url.as_str()).collect(); assert_eq!( urls, vec![ "https://0807.st/d/x", "https://gofile.io/d/a", "https://gofile.io/d/b" ] ); assert!(found.iter().all(|f| f.message_id.is_none())); } #[test] fn load_urls_file_warns_unsupported() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("urls.txt"); fs::write(&p, "https://example.com/x\n").unwrap(); let found = load_urls_file(&p).unwrap(); assert!(found.is_empty()); } #[test] fn write_failed_urls_empty_and_filled() { let dir = tempfile::tempdir().unwrap(); let mut state = State::load(dir.path().join("state.json")); let dest = write_failed_urls(dir.path(), &state).unwrap(); assert_eq!(fs::read_to_string(&dest).unwrap(), ""); state .mark("https://gofile.io/d/a", |r| r.status = "failed".into()) .unwrap(); state .mark("https://gofile.io/d/b", |r| r.status = "ok".into()) .unwrap(); let dest = write_failed_urls(dir.path(), &state).unwrap(); assert_eq!( fs::read_to_string(&dest).unwrap(), "https://gofile.io/d/a\n" ); } #[test] fn record_files_writes_state_global_and_manifest() { let dir = tempfile::tempdir().unwrap(); let mut state = State::load(dir.path().join("state.json")); let manifest = dir.path().join("manifest.jsonl"); let mut gs = GlobalStore::load(dir.path().join("urls.json")); let source = SourceInfo { channel: "123".into(), host: Some("gofile".into()), message_id: Some(Value::from(42)), timestamp: Some("2024-01-01".into()), author: Some("ben".into()), manifest: manifest.clone(), }; let files = vec![serde_json::json!({"path": "/tmp/x.7z", "size": 10, "name": "x.7z"})]; record_files( &mut state, Some(&mut gs), "https://gofile.io/d/a", &files, &source, &manifest, ) .unwrap(); assert_eq!(state.records["https://gofile.io/d/a"].status, "ok"); assert_eq!(state.records["https://gofile.io/d/a"].files.len(), 1); assert!(gs.is_done("https://gofile.io/d/a")); let mtext = fs::read_to_string(&manifest).unwrap(); let row: Value = serde_json::from_str(mtext.trim()).unwrap(); assert_eq!(row["channel"], "123"); assert_eq!(row["message_id"], 42); assert_eq!(row["path"], "/tmp/x.7z"); assert_eq!(row["name"], "x.7z"); } #[test] fn extract_zips_skips_missing_and_non_zip() { // non-zip entries are ignored, no panic extract_zips(&[serde_json::json!({"path": "/definitely/not/there.7z"})]); extract_zips(&[]); } #[test] fn fisher_yates_is_permutation() { let mut v: Vec = (0..50).collect(); fisher_yates(&mut v); let mut sorted = v.clone(); sorted.sort_unstable(); assert_eq!(sorted, (0..50).collect::>()); } #[test] fn load_subscriptions_both_shapes() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("subs.json"); fs::write( &p, r#"{"subscriptions": [{"name": "A", "channel_id": "1", "out_dir": "out_a"}]}"#, ) .unwrap(); let subs = load_subscriptions(&p).unwrap(); assert_eq!( subs, vec![("1".to_string(), "out_a".to_string(), Some("A".to_string()))] ); fs::write(&p, r#"[{"channel_id": "2", "out_dir": "out_b"}]"#).unwrap(); let subs = load_subscriptions(&p).unwrap(); assert_eq!(subs, vec![("2".to_string(), "out_b".to_string(), None)]); } #[test] fn load_subscriptions_rejects_missing_fields() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("subs.json"); fs::write(&p, r#"[{"channel_id": "2"}]"#).unwrap(); assert!(load_subscriptions(&p).is_err()); } #[test] fn parse_since_relative_and_absolute() { let now = Utc::now(); let cutoff = parse_since("5d").unwrap(); assert!(cutoff <= now); assert!(cutoff > now - chrono::Duration::days(6)); assert!(parse_since("12h").unwrap() > now - chrono::Duration::hours(13)); assert_eq!( parse_since("2026-10-04").unwrap().to_rfc3339(), "2026-10-04T00:00:00+00:00" ); assert_eq!( parse_since("2026-10-04T09:30:00Z").unwrap().to_rfc3339(), "2026-10-04T09:30:00+00:00" ); assert_eq!( parse_since("2026-10-04 09:30:00").unwrap().to_rfc3339(), "2026-10-04T09:30:00+00:00" ); assert!(parse_since("nonsense").is_err()); } #[test] fn torrent_detection() { assert!(is_torrent_url("https://0807.st/rh0BzdD.torrent")); assert!(is_torrent_url("https://0807.st/x.TORRENT")); assert!(!is_torrent_url("https://0807.st/rh0BzdD.zip")); assert!(is_torrent_file("Lord of Skulls.torrent")); assert!(!is_torrent_file("Lord of Skulls.rar")); } #[test] fn parse_message_ts_handles_export_formats() { assert_eq!( parse_message_ts("2026-01-01T00:00:00") .unwrap() .to_rfc3339(), "2026-01-01T00:00:00+00:00" ); assert!(parse_message_ts("2026-01-01T00:00:00.123000+00:00").is_some()); assert!(parse_message_ts("garbage").is_none()); } }