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 {
} 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(())
}