qbt-clean 0.3.0

Automated rules-based cleaning of qBittorrent torrents.
use anyhow::Context as _;
use futures::StreamExt as _;
use futures::TryStreamExt as _;
use std::fmt::Write as _;

mod config; pub(crate) use config::*;
mod float; pub(crate) use float::*;
mod fmt; pub(crate) use fmt::*;
mod group; pub(crate) use group::*;
mod info_hash; pub(crate) use info_hash::*;
mod parse; pub(crate) use parse::*;
mod serde_util; pub(crate) use serde_util::*;
mod torrent; pub(crate) use torrent::*;
mod units; pub(crate) use units::*;

#[derive(Debug,serde::Deserialize)]
struct Sync {
	server_state: ServerState,
}

#[derive(Debug,serde::Deserialize)]
struct ServerState {
	free_space_on_disk: u64,
}

#[derive(Debug,serde::Deserialize)]
struct File {
	size: u64,
}

#[derive(Debug,serde::Deserialize)]
struct Tracker {
	url: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
	let mut config: Config = simple_config::from_file(
		std::env::args().nth(1).expect("First argument should be the config file."))?;

	if config.age_d_max_weight == 0.0
		&& config.copies_weight == 0.0
		&& config.last_activity_d_weight == 0.0
		&& config.seeder_count_weight == 0.0
		&& config.size_weight == 0.0
	{
		eprintln!("All weights are zero, setting seeder_count_weight to 1.0");
		config.seeder_count_weight = 1.0;
	}

	let http = reqwest::Client::builder()
		.user_agent("qbt-clean/0")
		.connect_timeout(std::time::Duration::from_secs(60))
		.timeout(std::time::Duration::from_secs(600))
		.cookie_store(true)
		.build().unwrap();

	let mut qbt = config.qbt_url.clone();
	qbt.set_username("").map_err(|()| anyhow::Error::msg("Invalid qbt_url"))?;
	qbt.set_password(None).map_err(|()| anyhow::Error::msg("Invalid qbt_url"))?;

	http.post(qbt.join("/api/v2/auth/login")?)
		.form(&[
			("username", config.qbt_url.username()),
			("password", config.qbt_url.password().unwrap_or("")),
		])
		.send().await.context("Sending login request")?
		.error_for_status().context("Login request status")?;

	eprintln!("Logged in.");

	let system = http.get(qbt.join("/api/v2/sync/maindata")?)
		.send().await?
		.error_for_status()?
		.json::<Sync>().await?;

	let Some(to_free) = config.target_free.checked_sub(system.server_state.free_space_on_disk)
		else {
			eprintln!(
				"Have {} free which satisfies target_free of {}, nothing to do.",
				crate::FmtBytes(system.server_state.free_space_on_disk),
				crate::FmtBytes(config.target_free));
			return Ok(())
		};
	let mut to_free = std::num::Saturating(to_free);

	eprintln!(
		"Going to free {} to hit target of {} free space.",
		crate::FmtBytes(to_free.0),
		crate::FmtBytes(config.target_free));

	let now = std::time::SystemTime::now();

	let torrents = http.get(qbt.join("/api/v2/torrents/info")?)
		.send().await?
		.error_for_status()?
		.json::<Vec<Torrent>>().await?;

	let groups = std::sync::Mutex::<
		std::collections::HashMap::<
			u64,
			std::sync::Arc<
				tokio::sync::Mutex<Group>>>>::default();

	let () = futures::stream::iter(torrents)
		.map(async |t| -> anyhow::Result<()> {
			let files = http.get(qbt.join("/api/v2/torrents/files")?)
				.query(&[
					("hash", t.hash),
				])
				.send().await?
				.error_for_status()?
				.json::<Vec<File>>().await?;

			let average_size = t.total_size / u64::try_from(files.len())?;
			let cutoff = average_size / 2;

			let mut counted_size = 0;
			for f in files {
				if f.size >= cutoff {
					counted_size += f.size;
				}
			}

			let group = {
				groups
					.lock().unwrap()
					.entry(counted_size)
					.or_default()
					.clone()
			};
			let mut group = group.lock().await;

			if group.pinned {
				// Already pinned.
			} else if t.seeding_time < std::time::Duration::from_secs(14 * 24 * 3600) {
				group.pinned = true;
			} else if t.seeders < 3 {
				group.pinned = true;
			} else if t.last_activity + std::time::Duration::from_secs(7 * 24 * 3600) > now {
				group.pinned = true;
			} else if !config.state_allowed.contains(&t.state) {
				group.pinned = true;
			} else if !config.categories_allowed.contains(&t.category) {
				group.pinned = true;
			} else if config.names_pinned.is_match(&t.name) {
				group.pinned = true;
			} else {
				let trackers = http.get(qbt.join("/api/v2/torrents/trackers")?)
					.query(&[
						("hash", t.hash),
					])
					.send().await?
					.error_for_status()?
					.json::<Vec<Tracker>>().await?;

				for tr in trackers {
					if config.trackers_pinned.is_match(&tr.url) {
						group.pinned = true;
						break
					}
				}
			}

			group.torrents.push(t);

			Ok(())
		})
		.buffer_unordered(8)
		.try_collect().await?;

	let mut groups = groups.into_inner()?;
	groups.retain(|_, g| {
		!std::sync::Arc::get_mut(g).unwrap().get_mut().pinned
	});

	let mut groups = groups.into_iter()
		.map(|(_, g)| std::sync::Arc::into_inner(g).unwrap().into_inner())
		.collect::<Vec<_>>();
	groups.sort_unstable_by_key(|g| {
		std::cmp::Reverse(crate::F64Ord(
			g.min_age(now).as_secs_f64() / 3600.0 / 24.0 * config.age_d_max_weight
			+ g.last_activity(now).as_secs_f64() / 3600.0 / 24.0 * config.last_activity_d_weight
			+ g.seeders() as f64 * config.seeder_count_weight
			+ g.size() as f64 * config.size_weight))
	});

	let mut removed_bytes = 0u64;
	let mut removed_groups = 0u64;
	let mut removed_torrents = 0u64;

	let mut hashes = String::new();
	for g in groups {
		to_free -= g.size();
		removed_bytes += g.size();
		removed_groups += 1;

		for (i, t) in g.torrents.into_iter().enumerate() {
			eprintln!(
				"DELETE {} {:?} size: {}, tracker: {}, category: {:?} seeders: {} ratio: {:.02}",
				t.hash,
				t.name,
				crate::FmtBytes(t.selected_size),
				t.tracker,
				t.category,
				t.seeders,
				t.ratio);

			if i == 0 {
				hashes.clear();
			} else {
				hashes.push('|');
			}
			write!(&mut hashes, "{}", t.hash)?;

			removed_torrents += 1;
		}

		if !config.dry_run {
			http.post(qbt.join("/api/v2/torrents/delete")?)
				.form(&[
					("deleteFiles", "true"),
					("hashes", &hashes),
				])
				.send().await?
				.error_for_status()?;
		}

		if to_free.0 == 0 { break }
	}

	eprintln!(
		"Deleted {} torrents in {} groups. Aprox {} freed.",
		removed_torrents,
		removed_groups,
		crate::FmtBytes(removed_bytes));

	if to_free.0 > 0 {
		eprintln!("No more eligible torrents to reach free space target.");
	} else {
		eprintln!("Free space target met.");
	}
	Ok(())
}