Files
Warhammer_DLP_Helper/version-rust/src/orchestrate.rs
T
Ben_Kosytorz ef5cf04c52 feat(rust): never download .torrent links or torrent files in gofile content
User policy: the channel hosts user-created torrents (one ~2.5 TB); this
tool is for direct-download packs. Torrent links are recorded as done with
a note so they never re-download.
2026-10-10 09:17:00 +02:00

1199 lines
42 KiB
Rust

//! 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<String>,
pub limit: Option<usize>,
pub shuffle: bool,
pub only: Option<String>,
/// only URLs posted at/after this point (Rust-only extension: "5d",
/// "12h", "2026-10-04", or an RFC 3339 timestamp)
pub since: Option<String>,
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<DateTime<Utc>> {
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<DateTime<Utc>> {
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<String> {
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<Vec<String>> {
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<Vec<FoundUrl>> {
let mut found: HashMap<String, FoundUrl> = 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<FoundUrl> = 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<FileEntry> = 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<String>,
pub message_id: Option<Value>,
pub timestamp: Option<String>,
pub author: Option<String>,
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<PathBuf> {
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<usize> {
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<u64> {
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<FoundUrl> = 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<FoundUrl> = if let Some(since) = &opts.since {
let cutoff = parse_since(since)?;
let before = found.len();
let kept: Vec<FoundUrl> = 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<FoundUrl> = Vec::new();
let mut z0807_urls: Vec<FoundUrl> = 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<String, SourceInfo> = 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<FoundUrl> = 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 <id> <out> --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<T>(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<String, SourceInfo>,
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<Vec<Value>> {
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<Value> = 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::<GoFileError>() 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<String, SourceInfo>,
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::<Zero807Error>() 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<Vec<(String, String, Option<String>)>> {
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<u32> = (0..50).collect();
fisher_yates(&mut v);
let mut sorted = v.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..50).collect::<Vec<_>>());
}
#[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());
}
}