From bbaa7c5f54e35c4581c569ed7947918100903fd8 Mon Sep 17 00:00:00 2001 From: yggverse Date: Tue, 5 Aug 2025 02:07:22 +0300 Subject: [PATCH] begin the rocket framework implementation, based on the aquatic-crawler fs --- Cargo.toml | 20 +-- README.md | 3 +- src/api.rs | 53 ------- src/api/info_hash.rs | 15 -- src/config.rs | 89 +++--------- src/feed.rs | 140 +++++++++++++++++++ src/format.rs | 17 +++ src/main.rs | 320 ++++++------------------------------------- src/peers.rs | 21 --- src/preload.rs | 76 ---------- src/storage.rs | 180 ++++++++++++++++++++++++ src/trackers.rs | 20 --- 12 files changed, 409 insertions(+), 545 deletions(-) delete mode 100644 src/api.rs delete mode 100644 src/api/info_hash.rs create mode 100644 src/feed.rs create mode 100644 src/format.rs delete mode 100644 src/peers.rs delete mode 100644 src/preload.rs create mode 100644 src/storage.rs delete mode 100644 src/trackers.rs diff --git a/Cargo.toml b/Cargo.toml index 1e98d5e..ebe060d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,24 +4,16 @@ version = "0.1.0" edition = "2024" license = "MIT" readme = "README.md" -description = "Crawler daemon for the yggtracker-redb index, based on the librqbit API" -keywords = ["aquatic", "librqbit", "yggtracker", "crawler", "bittorrent"] +description = "BitTorrent aggregation web-server, based on the Rocket framework and aquatic-crawler FS" +keywords = ["yggtracker", "bittorrent", "server", "aggregator", "catalog"] categories = ["network-programming"] repository = "https://github.com/YGGverse/yggtrackerd" # homepage = "https://yggverse.github.io" [dependencies] -anyhow = "1.0" -chrono = "0.4.41" clap = { version = "4.5", features = ["derive"] } -librqbit = {version = "9.0.0-beta.1", features = ["disable-upload"] } -tokio = { version = "1.45", features = ["full"] } -tracing-subscriber = "0.3" +rocket = "0.5" +librqbit-core = "5.0" +chrono = "0.4.41" url = "2.5" -urlencoding = "2.1" -libyggtracker-redb = "0.1" - -[patch.crates-io] -librqbit = { git = "https://github.com/ikatson/rqbit.git", rev="b580a9610ae7c6eaacd305a3905f7e2d3202ca69" } -libyggtracker-redb = { git = "https://github.com/YGGverse/libyggtracker-redb.git", rev="e567777ec172a8bf011483c1f49bf1d444543753" } -# libyggtracker-redb = { path = "../libyggtracker-redb" } +urlencoding = "2.1" \ No newline at end of file diff --git a/README.md b/README.md index f3085b7..94026f7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Dependencies](https://deps.rs/repo/github/YGGverse/yggtrackerd/status.svg)](https://deps.rs/repo/github/YGGverse/yggtrackerd) [![crates.io](https://img.shields.io/crates/v/yggtrackerd.svg)](https://crates.io/crates/yggtrackerd) -Crawler daemon for the yggtracker-redb index, based on the librqbit API +BitTorrent aggregation web-server, based on the [Rocket](https://rocket.rs) framework and [aquatic-crawler](https://github.com/YGGverse/aquatic-crawler) FS ## Install @@ -22,6 +22,7 @@ yggtrackerd --infohash /path/to/info-hash-ipv6.bin\ --database /path/to/index.redb\ --preload /path/to/directory ``` +* append `RUST_LOG=debug` for detailed information output ### Options diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index 220a523..0000000 --- a/src/api.rs +++ /dev/null @@ -1,53 +0,0 @@ -mod info_hash; -use info_hash::InfoHash; - -/// Parse infohash from the source filepath, -/// decode hash bytes to `InfoHash` array on success. -/// -/// * return `None` if the `path` is not reachable -pub fn get(path: &str, capacity: usize) -> Option> { - use std::io::Read; - if !path.ends_with(".bin") { - todo!("Only sources in the `.bin` format are supported!") - } - if path.contains("://") { - todo!("URL source format is not supported!") - } - const L: usize = 20; // v1 only - let mut r = Vec::with_capacity(capacity); - let mut f = std::fs::File::open(path).ok()?; - loop { - let mut b = [0; L]; - if f.read(&mut b).ok()? != L { - break; - } - r.push(InfoHash::V1(b)) - } - Some(r) -} - -#[test] -fn test() { - use std::fs; - - #[cfg(not(any(target_os = "linux", target_os = "macos",)))] - { - todo!() - } - - const C: usize = 2; - - const P0: &str = "/tmp/yggtrackerd-api-test-0.bin"; - const P1: &str = "/tmp/yggtrackerd-api-test-1.bin"; - const P2: &str = "/tmp/yggtrackerd-api-test-2.bin"; - - fs::write(P0, vec![]).unwrap(); - fs::write(P1, vec![1; 40]).unwrap(); // 20 + 20 bytes - - assert!(get(P0, C).is_some_and(|b| b.is_empty())); - assert!(get(P1, C).is_some_and(|b| b.len() == 2)); - assert!(get(P2, C).is_none()); - - fs::remove_file(P0).unwrap(); - fs::remove_file(P1).unwrap(); -} diff --git a/src/api/info_hash.rs b/src/api/info_hash.rs deleted file mode 100644 index 20827f6..0000000 --- a/src/api/info_hash.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub enum InfoHash { - V1([u8; 20]), -} - -impl std::fmt::Display for InfoHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::V1(i) => write!( - f, - "{}", - i.iter().map(|b| format!("{b:02x}")).collect::() - ), - } - } -} diff --git a/src/config.rs b/src/config.rs index b92f5f6..bfd3e74 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,86 +1,35 @@ use clap::Parser; use std::path::PathBuf; +use url::Url; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] pub struct Config { - /// Path to the permanent [redb](https://www.redb.org) database + /// Path to the [aquatic-crawler](https://github.com/YGGverse/aquatic-crawler) file storage #[arg(long, short)] - pub database: PathBuf, + pub storage: PathBuf, - /// Print debug output - #[arg(long, default_value_t = false)] - pub debug: bool, + /// Default listing limit + #[arg(long, default_value_t = 50)] + pub limit: usize, - /// Absolute path(s) or URL(s) to import infohashes from the Aquatic tracker binary API - /// - /// * PR#233 feature ([Wiki](https://github.com/YGGverse/aquatic-crawler/wiki/Aquatic)) - #[arg(long, short)] - pub infohash: Vec, - - /// Define custom tracker(s) to preload the `.torrent` files info - #[arg(long, short)] - pub tracker: Vec, - - /// Define initial peer(s) to preload the `.torrent` files info - #[arg(long)] - pub initial_peer: Vec, - - /// Appends `--tracker` value to magnets and torrents - #[arg(long, default_value_t = false)] - pub export_trackers: bool, - - /// Enable DHT resolver - #[arg(long, default_value_t = false)] - pub enable_dht: bool, - - /// Bind resolver session on specified device name (`tun0`, `mycelium`, etc.) - #[arg(long)] - pub bind: Option, - - /// Directory path to store temporary preload data - #[arg(long, short)] - pub preload: PathBuf, - - /// Max size sum of preloaded files per torrent (match `preload_regex`) - #[arg(long)] - pub preload_max_filesize: Option, - - /// Max count of preloaded files per torrent (match `preload_regex`) - #[arg(long)] - pub preload_max_filecount: Option, - - /// Use `socks5://[username:password@]host:port` - #[arg(long)] - pub proxy_url: Option, - - // Peer options - #[arg(long)] - pub peer_connect_timeout: Option, - - #[arg(long)] - pub peer_read_write_timeout: Option, - - #[arg(long)] - pub peer_keep_alive_interval: Option, - - /// Estimated info-hash index capacity + /// Default capacity (estimated torrents in `storage`) #[arg(long, default_value_t = 1000)] - pub index_capacity: usize, + pub capacity: usize, - /// Max time to handle each torrent - #[arg(long, default_value_t = 10)] - pub add_torrent_timeout: u64, + /// Server name + #[arg(long, default_value_t = String::from("YGGtracker"))] + pub title: String, - /// Crawl loop delay in seconds - #[arg(long, default_value_t = 300)] - pub sleep: u64, - - /// Limit upload speed (b/s) + /// Server description #[arg(long)] - pub upload_limit: Option, + pub description: Option, - /// Limit download speed (b/s) + /// Canonical URL #[arg(long)] - pub download_limit: Option, + pub link: Option, + + /// Appends following tracker(s) to the magnet links + #[arg(long)] + pub tracker: Option>, } diff --git a/src/feed.rs b/src/feed.rs new file mode 100644 index 0000000..8c2daa7 --- /dev/null +++ b/src/feed.rs @@ -0,0 +1,140 @@ +use crate::format; +use std::collections::HashSet; +use url::Url; + +/// Export crawl index to the RSS file +pub struct Feed { + description: Option, + link: Option, + title: String, + /// Valid, parsed from Url, ready-to-use address string donor + trackers: Option>, +} + +impl Feed { + pub fn init( + title: String, + description: Option, + link: Option, + trackers: Option>, + ) -> Self { + Self { + description: description.map(escape), + link: link.map(|s| escape(s.to_string())), + title: escape(title), + trackers: trackers.map(|v| v.into_iter().map(|u| u.to_string()).collect()), + } + } + + pub fn transaction(&self, capacity: usize) -> String { + let t = chrono::Utc::now().to_rfc2822(); + let mut b = String::with_capacity(capacity); + + b.push_str(""); + + b.push_str(""); + b.push_str(&t); + b.push_str(""); + + b.push_str(""); + b.push_str(&t); + b.push_str(""); + + b.push_str(""); + b.push_str(&self.title); + b.push_str(""); + + if let Some(ref description) = self.description { + b.push_str(""); + b.push_str(description); + b.push_str("") + } + + if let Some(ref link) = self.link { + b.push_str(""); + b.push_str(link); + b.push_str("") + } + b + } + + /// Append `item` to the feed `channel` + pub fn push(&self, buffer: &mut String, torrent: crate::storage::Torrent) { + buffer.push_str(&format!( + "{}{}{}", + &torrent.info_hash, + escape( + torrent + .name + .as_ref() + .map(|b| b.to_string()) + .unwrap_or("?".into()) // @TODO + ), + escape(self.magnet(&torrent.info_hash)) + )); + + if let Some(d) = item_description(torrent.length, torrent.files) { + buffer.push_str(""); + buffer.push_str(&escape(d)); + buffer.push_str("") + } + + buffer.push_str(""); + buffer.push_str(&torrent.time.to_rfc2822()); + buffer.push_str(""); + + buffer.push_str("") + } + + /// Write final bytes + pub fn commit(&self, mut buffer: String) -> String { + buffer.push_str(""); + buffer + } + + // Tools + + fn magnet(&self, info_hash: &str) -> String { + let mut b = if info_hash.len() == 40 { + format!("magnet:?xt=urn:btih:{info_hash}") + } else { + todo!("info-hash v2 is not supported by librqbit") + }; + if let Some(ref trackers) = self.trackers { + for tracker in trackers { + b.push_str("&tr="); + b.push_str(&urlencoding::encode(tracker)) + } + } + b + } +} + +fn escape(subject: String) -> String { + subject + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace("'", "'") +} + +fn item_description(size: Option, list: Option>) -> Option { + if size.is_none() && list.is_none() { + return None; + } + let mut b = Vec::with_capacity(list.as_ref().map(|l| l.len()).unwrap_or_default() + 1); + if let Some(s) = size { + b.push(format::bytes(s)) + } + if let Some(files) = list { + for file in files { + b.push(format!( + "{} ({})", + file.name.as_deref().unwrap_or("?"), // @TODO invalid encoding + format::bytes(file.length) + )) + } + } + Some(b.join("\n")) +} diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..e5ffd96 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,17 @@ +pub fn bytes(value: u64) -> String { + const KB: f32 = 1024.0; + const MB: f32 = KB * KB; + const GB: f32 = MB * KB; + + let f = value as f32; + + if f < KB { + format!("{value} B") + } else if f < MB { + format!("{:.2} KB", f / KB) + } else if f < GB { + format!("{:.2} MB", f / MB) + } else { + format!("{:.2} GB", f / GB) + } +} diff --git a/src/main.rs b/src/main.rs index 989e380..c796004 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,283 +1,53 @@ -mod api; +#[macro_use] +extern crate rocket; + mod config; -mod peers; -mod preload; -mod trackers; +mod feed; +mod format; +mod storage; -use anyhow::Result; use config::Config; -use librqbit::{ - AddTorrent, AddTorrentOptions, AddTorrentResponse, ConnectionOptions, PeerConnectionOptions, - SessionOptions, +use feed::Feed; +use rocket::{ + State, + http::Status, + response::{content::RawXml, status::Custom}, }; -use libyggtracker_redb::{ - Database, - torrent::{Image, Torrent, image}, -}; -use peers::Peers; -use preload::Preload; -use std::{collections::HashSet, num::NonZero, os::unix::ffi::OsStrExt, time::Duration}; -use trackers::Trackers; -use url::Url; +use storage::{Order, Sort, Storage}; -#[tokio::main] -async fn main() -> Result<()> { - use chrono::Local; +#[get("/")] +pub fn index() -> &'static str { + "Catalog in development, use /rss" +} + +#[get("/rss")] +pub fn rss(feed: &State, storage: &State) -> Result, Custom> { + let mut b = feed.transaction(1024); // @TODO + for torrent in storage + .torrents( + Some((Sort::Modified, Order::Asc)), + Some(storage.default_limit), + ) + .map_err(|e| Custom(Status::InternalServerError, e.to_string()))? + { + feed.push(&mut b, torrent) + } + Ok(RawXml(feed.commit(b))) +} + +#[launch] +fn rocket() -> _ { use clap::Parser; - use tokio::time; - - // init components - let time_init = Local::now(); let config = Config::parse(); - if std::env::var("RUST_LOG").is_ok() { - tracing_subscriber::fmt::init() - } // librqbit impl dependency - let database = Database::init(&config.database)?; - let peers = Peers::init(&config.initial_peer)?; - let preload = Preload::init( - config.preload, - config.preload_max_filecount, - config.preload_max_filesize, - )?; - let trackers = Trackers::init(&config.tracker)?; - let session = librqbit::Session::new_with_opts( - preload.root().clone(), - SessionOptions { - bind_device_name: config.bind, - listen: None, - connect: Some(ConnectionOptions { - enable_tcp: true, - proxy_url: config.proxy_url, - peer_opts: Some(PeerConnectionOptions { - connect_timeout: config.peer_connect_timeout.map(Duration::from_secs), - read_write_timeout: config.peer_read_write_timeout.map(Duration::from_secs), - keep_alive_interval: config.peer_keep_alive_interval.map(Duration::from_secs), - }), - }), - disable_upload: false, - disable_dht: !config.enable_dht, - disable_dht_persistence: true, - persistence: None, - ratelimits: librqbit::limits::LimitsConfig { - upload_bps: config.upload_limit.and_then(NonZero::new), - download_bps: config.download_limit.and_then(NonZero::new), - }, - trackers: trackers.list().clone(), - ..SessionOptions::default() - }, - ) - .await?; - - // begin - println!("Crawler started on {time_init}"); - loop { - let time_queue = Local::now(); - if config.debug { - println!("\tQueue crawl begin on {time_queue}...") - } - for source in &config.infohash { - if config.debug { - println!("\tIndex source `{source}`...") - } - // grab latest info-hashes from this source - // * aquatic server may update the stats at this moment, handle result manually - for i in match api::get(source, config.index_capacity) { - Some(i) => i, - None => { - // skip without panic - if config.debug { - eprintln!( - "The feed `{source}` has an incomplete format (or is still updating); skip." - ) - } - continue; - } - } { - // convert to string once - let i = i.to_string(); - // already indexed? - if database.has_torrent(&i)? { - continue; - } - if config.debug { - println!("\t\tIndex `{i}`...") - } - // run the crawler in single thread for performance reasons, - // use `timeout` argument option to skip the dead connections. - match time::timeout( - Duration::from_secs(config.add_torrent_timeout), - session.add_torrent( - AddTorrent::from_url(magnet( - &i, - if config.export_trackers && !trackers.is_empty() { - Some(trackers.list()) - } else { - None - }, - )), - Some(AddTorrentOptions { - paused: true, // continue after `only_files` init - overwrite: true, - disable_trackers: trackers.is_empty(), - initial_peers: peers.initial_peers(), - list_only: false, // we want to grab the images - // it is important to blacklist all files preload until initiation - only_files: Some(Vec::with_capacity( - config.preload_max_filecount.unwrap_or_default(), - )), - // the folder to preload temporary files (e.g. images for the audio albums) - output_folder: Some( - preload.output_folder(&i)?.to_string_lossy().to_string(), - ), - ..Default::default() - }), - ), - ) - .await - { - Ok(r) => match r { - Ok(AddTorrentResponse::Added(id, mt)) => { - let mut only_files = HashSet::with_capacity( - config - .preload_max_filecount - .unwrap_or(config.index_capacity), - ); - let mut images = Vec::with_capacity( - config - .preload_max_filecount - .unwrap_or(config.index_capacity), - ); - mt.wait_until_initialized().await?; - let bytes = mt.with_metadata(|m| { - for info in &m.file_infos { - if preload.max_filecount.is_some_and(|limit| only_files.len() + 1 > limit) { - if config.debug { - println!( - "\t\t\ttotal files count limit ({}) for `{i}` reached!", - preload.max_filecount.unwrap() - ) - } - break; - } - if info.relative_filename.extension().is_none_or(|e| - !matches!(e.as_bytes(), b"png" | b"jpeg" | b"jpg" | b"gif" | b"webp")) { - continue; - } - if preload.max_filesize.is_some_and(|limit| info.len > limit) { - if config.debug { - println!( - "\t\t\ttotal files size limit `{i}` reached!" - ) - } - continue; - } - assert!(only_files.insert(id)); - images.push(info.relative_filename.clone()); - } - m.info_bytes.to_vec() - })?; - session.update_only_files(&mt, &only_files).await?; - session.unpause(&mt).await?; - mt.wait_until_completed().await?; - - // persist torrent data resolved - database.set_torrent( - &i, - Torrent { - bytes, - images: if images.is_empty() { - None - } else { - Some( - images - .into_iter() - .filter_map(|p| { - extension(&p).map(|extension| Image { - alt: p.to_str().map(|s| s.to_string()), - bytes: preload.bytes(&p).unwrap(), - extension, - }) - }) - .collect(), - ) - }, - time: chrono::Utc::now(), - }, - )?; - - // remove torrent from session as indexed - session - .delete(librqbit::api::TorrentIdOrHash::Id(id), false) - .await?; - - // cleanup `output_folder` only if the torrent is resolved - // to prevent extra write operations on the next iteration - preload.clear_output_folder(&i)?; - - if config.debug { - println!("\t\t\ttorrent data successfully resolved.") - } - } - Ok(_) => panic!(), - Err(e) => eprintln!("Failed to resolve `{i}`: `{e}`."), - }, - Err(e) => { - if config.debug { - println!("\t\t\tfailed to resolve `{i}`: `{e}`") - } - } - } - } - } - if config.debug { - println!( - "Queue completed on {time_queue}\n\ttime: {} s\n\tuptime: {} s\n\tawait {} seconds to continue...", - Local::now() - .signed_duration_since(time_queue) - .as_seconds_f32(), - Local::now() - .signed_duration_since(time_init) - .as_seconds_f32(), - config.sleep, - ) - } - std::thread::sleep(Duration::from_secs(config.sleep)) - } -} - -/// Build magnet URI -fn magnet(infohash: &str, trackers: Option<&HashSet>) -> String { - let mut m = if infohash.len() == 40 { - format!("magnet:?xt=urn:btih:{infohash}") - } else { - todo!("infohash v2 is not supported by librqbit") - }; - if let Some(t) = trackers { - for tracker in t { - m.push_str("&tr="); - m.push_str(&urlencoding::encode(tracker.as_str())) - } - } - m -} - -use image::Extension; -fn extension(path: &std::path::Path) -> Option { - match path.extension() { - Some(p) => { - let e = p.to_string_lossy().to_lowercase(); - if e == "png" { - Some(Extension::Png) - } else if e == "jpeg" || e == "jpg" { - Some(Extension::Jpeg) - } else if e == "webp" { - Some(Extension::Webp) - } else if e == "gif" { - Some(Extension::Gif) - } else { - return None; - } - } - None => None, - } + let feed = Feed::init( + config.title, + config.description, + config.link, + config.tracker.map(|u| u.into_iter().collect()), // make sure it's unique + ); + let storage = Storage::init(config.storage, config.limit, config.capacity).unwrap(); // @TODO handle + rocket::build() + .manage(feed) + .manage(storage) + .mount("/", routes![index, rss]) } diff --git a/src/peers.rs b/src/peers.rs deleted file mode 100644 index f43da62..0000000 --- a/src/peers.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::{net::SocketAddr, str::FromStr}; - -pub struct Peers(Vec); - -impl Peers { - pub fn init(peers: &Vec) -> anyhow::Result { - let mut p = Vec::with_capacity(peers.len()); - for peer in peers { - p.push(SocketAddr::from_str(peer)?); - } - Ok(Self(p)) - } - - pub fn initial_peers(&self) -> Option> { - if self.0.is_empty() { - None - } else { - Some(self.0.clone()) - } - } -} diff --git a/src/preload.rs b/src/preload.rs deleted file mode 100644 index 460d666..0000000 --- a/src/preload.rs +++ /dev/null @@ -1,76 +0,0 @@ -use anyhow::{Result, bail}; -use std::{fs, path::PathBuf}; - -/// Temporary file storage for `librqbit` preload data -pub struct Preload { - root: PathBuf, - pub max_filecount: Option, - pub max_filesize: Option, -} - -impl Preload { - pub fn init( - directory: PathBuf, - max_filecount: Option, - max_filesize: Option, - ) -> Result { - if !directory.is_dir() { - bail!("Preload location is not directory!"); - } - Ok(Self { - max_filecount, - max_filesize, - root: directory.canonicalize()?, - }) - } - - pub fn clear_output_folder(&self, info_hash: &str) -> Result<()> { - if !is_info_hash(info_hash) { - bail!("Invalid info-hash `{info_hash}`") - } - let mut p = PathBuf::from(&self.root); - p.push(info_hash); - if !p.is_dir() { - bail!( - "Requested target `{}` is not directory!", - p.to_string_lossy() - ) - } - Ok(fs::remove_dir_all(&p)?) - } - - /// * create new directory if not exists - pub fn output_folder(&self, info_hash: &str) -> Result { - if !is_info_hash(info_hash) { - bail!("Invalid info-hash `{info_hash}`") - } - let mut p = PathBuf::from(&self.root); - p.push(info_hash); - if !p.exists() { - fs::create_dir(&p)? - } - Ok(p) - } - - pub fn root(&self) -> &PathBuf { - &self.root - } - - pub fn bytes(&self, relative: &PathBuf) -> Result> { - let mut p = PathBuf::from(&self.root); - p.push(relative); - // make sure that given relative path - // does not contain relative navigation entities - if !p.canonicalize()?.starts_with(&self.root) { - bail!( - "Unexpected absolute path resolved for `{}`!", - p.to_string_lossy() - ) - } - Ok(std::fs::read(p)?) - } -} - -fn is_info_hash(value: &str) -> bool { - value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) -} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..61e9317 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,180 @@ +use chrono::{DateTime, Utc}; +use librqbit_core::{torrent_metainfo, torrent_metainfo::TorrentMetaV1Owned}; +use std::{ + fs::{self, DirEntry}, + path::PathBuf, +}; + +#[derive(Clone, Debug, Default)] +pub enum Sort { + #[default] + Modified, +} + +#[derive(Clone, Debug, Default)] +pub enum Order { + #[default] + Asc, + Desc, +} + +pub struct File { + pub name: Option, + pub length: u64, +} + +pub struct Torrent { + pub announce: Option, + pub comment: Option, + pub created_by: Option, + pub creation_date: Option>, + pub files: Option>, + pub info_hash: String, + pub is_private: bool, + pub length: Option, + pub name: Option, + pub publisher_url: Option, + pub publisher: Option, + /// File (modified) + pub time: DateTime, +} + +pub struct Storage { + pub default_limit: usize, + default_capacity: usize, + root: PathBuf, +} + +impl Storage { + // Constructors + + pub fn init( + root: PathBuf, + default_limit: usize, + default_capacity: usize, + ) -> Result { + if !root.is_dir() { + return Err("Storage root is not directory".into()); + } + Ok(Self { + default_limit, + default_capacity, + root: root.canonicalize().map_err(|e| e.to_string())?, + }) + } + + // Getters + + pub fn torrents( + &self, + sort_order: Option<(Sort, Order)>, + limit: Option, + ) -> Result, String> { + let f = self.files(sort_order)?; + let l = limit.unwrap_or(f.len()); + let mut b = Vec::with_capacity(l); + for file in f.into_iter().take(l) { + if file + .path() + .extension() + .is_none_or(|e| e.is_empty() || e.to_string_lossy() != "torrent") + { + return Err("Unexpected file extension".into()); + } + let i: TorrentMetaV1Owned = torrent_metainfo::torrent_from_bytes( + &fs::read(file.path()).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + b.push(Torrent { + info_hash: i.info_hash.as_string(), + announce: i.announce.map(|a| a.to_string()), + comment: i.comment.map(|c| c.to_string()), + created_by: i.created_by.map(|c| c.to_string()), + creation_date: i + .creation_date + .map(|t| DateTime::from_timestamp_nanos(t as i64)), + files: i.info.files.map(|files| { + let limit = 1000; // @TODO + let mut b = Vec::with_capacity(files.len()); + let mut i = files.iter(); + let mut t = 0; + for f in i.by_ref() { + if t < limit { + t += 1; + b.push(File { + name: String::from_utf8( + f.path + .iter() + .enumerate() + .flat_map(|(n, b)| { + if n == 0 { + b.0.to_vec() + } else { + let mut p = vec![b'/']; + p.extend(b.0.to_vec()); + p + } + }) + .collect(), + ) + .ok(), + length: f.length, + }); + continue; + } + // limit reached: count sizes left and use placeholder as the last item name + let mut l = 0; + for f in i.by_ref() { + l += f.length + } + b.push(File { + name: Some("...".to_string()), + length: l, + }); + break; + } + b[..t].sort_by(|a, b| a.name.cmp(&b.name)); // @TODO optional + b + }), + publisher_url: i.publisher_url.map(|u| u.to_string()), + publisher: i.publisher.map(|p| p.to_string()), + is_private: i.info.private, + length: i.info.length, + name: i.info.name.map(|e| e.to_string()), + time: file + .metadata() + .map_err(|e| e.to_string())? + .modified() + .map_err(|e| e.to_string())? + .into(), + }) + } + Ok(b) + } + + // Helpers + + fn files(&self, sort_order: Option<(Sort, Order)>) -> Result, String> { + let mut b = Vec::with_capacity(self.default_capacity); + for entry in fs::read_dir(&self.root).map_err(|e| e.to_string())? { + let e = entry.map_err(|e| e.to_string())?; + match e.file_type() { + Ok(t) => { + if t.is_file() { + b.push((e.metadata().unwrap().modified().unwrap(), e)) + } + } + Err(e) => warn!("{}", e.to_string()), + } + } + if let Some((sort, order)) = sort_order { + match sort { + Sort::Modified => match order { + Order::Asc => b.sort_by(|a, b| a.0.cmp(&b.0)), + Order::Desc => b.sort_by(|a, b| b.0.cmp(&a.0)), + }, + } + } + Ok(b.into_iter().map(|e| e.1).collect()) + } +} diff --git a/src/trackers.rs b/src/trackers.rs deleted file mode 100644 index d01c1e2..0000000 --- a/src/trackers.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::{collections::HashSet, str::FromStr}; -use url::Url; - -pub struct Trackers(HashSet); - -impl Trackers { - pub fn init(trackers: &Vec) -> anyhow::Result { - let mut t = HashSet::with_capacity(trackers.len()); - for tracker in trackers { - t.insert(Url::from_str(tracker)?); - } - Ok(Self(t)) - } - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - pub fn list(&self) -> &HashSet { - &self.0 - } -}