From 8bf5e7a21f20a51661c5f938d19b69ec01b3bbf5 Mon Sep 17 00:00:00 2001 From: yggverse Date: Sat, 6 Sep 2025 18:31:49 +0300 Subject: [PATCH] initial commit --- .github/FUNDING.yml | 1 + .github/workflows/linux.yml | 24 +++++ .gitignore | 2 + Cargo.toml | 19 ++++ README.md | 7 +- src/lib.rs | 2 + src/public.rs | 180 ++++++++++++++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/linux.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/lib.rs create mode 100644 src/public.rs diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ada8a24 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://yggverse.github.io/#donate \ No newline at end of file diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..cb2e928 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,24 @@ +name: Linux + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - run: rustup update + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets + - run: cargo build --verbose + - run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..869df07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cbc7555 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "btracker-fs" +version = "0.1.0" +edition = "2024" +license = "MIT" +readme = "README.md" +description = "Shared filesystem API for the βtracker project components" +keywords = ["btracker", "bittorrent", "aquatic-crawler", "librqbit", "fs"] +categories = ["network-programming"] +repository = "https://github.com/yggverse/btracker-fs" +# homepage = "https://yggverse.github.io" + +[features] +# default = ["public"] +public = [] + +[dependencies] +chrono = { version = "0.4.41", features = ["serde"] } +librqbit-core = "5.0" \ No newline at end of file diff --git a/README.md b/README.md index 882b9ef..e372a53 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # btracker-fs -Shared filesystem API for the βtracker project + +![Linux](https://github.com/yggverse/btracker-fs/actions/workflows/linux.yml/badge.svg) +[![Dependencies](https://deps.rs/repo/github/yggverse/btracker-fs/status.svg)](https://deps.rs/repo/github/yggverse/btracker-fs) +[![crates.io](https://img.shields.io/crates/v/btracker-fs.svg)](https://crates.io/crates/btracker-fs) + +Shared filesystem API for the [βtracker](https://github.com/yggverse/btracker) project components diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e96a75a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "public")] +pub mod public; diff --git a/src/public.rs b/src/public.rs new file mode 100644 index 0000000..9a80c64 --- /dev/null +++ b/src/public.rs @@ -0,0 +1,180 @@ +use chrono::{DateTime, Utc}; +use std::{fs, io::Error, path::PathBuf, time::SystemTime}; + +#[derive(Clone, Debug, Default)] +pub enum Sort { + #[default] + Modified, +} + +#[derive(Clone, Debug, Default)] +pub enum Order { + #[default] + Asc, + Desc, +} + +pub struct Torrent { + pub bytes: Vec, + pub time: DateTime, +} + +pub struct Public { + default_capacity: usize, + pub default_limit: usize, + root: PathBuf, +} + +impl Public { + // Constructors + + pub fn init( + root: PathBuf, + default_limit: usize, + default_capacity: usize, + ) -> Result { + if !root.is_dir() { + return Err("Public root is not directory".into()); + } + Ok(Self { + default_capacity, + default_limit, + root: root.canonicalize().map_err(|e| e.to_string())?, + }) + } + + // Getters + + pub fn torrent(&self, info_hash: librqbit_core::Id20) -> Option { + let mut p = PathBuf::from(&self.root); + p.push(format!("{}.{E}", info_hash.as_string())); + Some(Torrent { + bytes: fs::read(&p).ok()?, + time: p.metadata().ok()?.modified().ok()?.into(), + }) + } + + pub fn torrents( + &self, + keyword: Option<&str>, + sort_order: Option<(Sort, Order)>, + start: Option, + limit: Option, + ) -> Result<(usize, Vec), Error> { + let f = self.files(keyword, sort_order)?; + let t = f.len(); + let l = limit.unwrap_or(t); + let mut b = Vec::with_capacity(l); + for file in f.into_iter().skip(start.unwrap_or_default()).take(l) { + b.push(Torrent { + bytes: fs::read(file.path)?, + time: file.modified.into(), + }) + } + Ok((t, b)) + } + + pub fn href(&self, info_hash: &str, path: &str) -> Option { + let mut relative = PathBuf::from(info_hash); + relative.push(path); + + let mut absolute = PathBuf::from(&self.root); + absolute.push(&relative); + + let c = absolute.canonicalize().ok()?; + if c.starts_with(&self.root) && c.exists() { + Some(relative.to_string_lossy().into()) + } else { + None + } + } + + // Helpers + + fn files( + &self, + keyword: Option<&str>, + sort_order: Option<(Sort, Order)>, + ) -> Result, Error> { + let mut files = Vec::with_capacity(self.default_capacity); + for dir_entry in fs::read_dir(&self.root)? { + let entry = dir_entry?; + let path = entry.path(); + if !path.is_file() || path.extension().is_none_or(|e| e != E) { + continue; + } + if let Some(k) = keyword + && !k.trim_matches(S).is_empty() + && !librqbit_core::torrent_metainfo::torrent_from_bytes(&fs::read(&path)?) + .is_ok_and(|m: librqbit_core::torrent_metainfo::TorrentMetaV1Owned| { + k.split(S) + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_lowercase()) + .all(|q| { + m.info_hash.as_string().to_lowercase().contains(&q) + || m.info + .name + .as_ref() + .is_some_and(|n| n.to_string().to_lowercase().contains(&q)) + || m.comment + .as_ref() + .is_some_and(|c| c.to_string().to_lowercase().contains(&q)) + || m.created_by + .as_ref() + .is_some_and(|c| c.to_string().to_lowercase().contains(&q)) + || m.publisher + .as_ref() + .is_some_and(|p| p.to_string().to_lowercase().contains(&q)) + || m.publisher_url + .as_ref() + .is_some_and(|u| u.to_string().to_lowercase().contains(&q)) + || m.announce + .as_ref() + .is_some_and(|a| a.to_string().to_lowercase().contains(&q)) + || m.announce_list.iter().any(|l| { + l.iter().any(|a| a.to_string().to_lowercase().contains(&q)) + }) + || m.info.files.as_ref().is_some_and(|f| { + f.iter().any(|f| { + let mut p = PathBuf::new(); + f.full_path(&mut p).is_ok_and(|_| { + p.to_string_lossy().to_lowercase().contains(&q) + }) + }) + }) + }) + }) + { + continue; + } + files.push(File { + modified: entry.metadata()?.modified()?, + path, + }) + } + if let Some((sort, order)) = sort_order { + match sort { + Sort::Modified => match order { + Order::Asc => files.sort_by(|a, b| a.modified.cmp(&b.modified)), + Order::Desc => files.sort_by(|a, b| b.modified.cmp(&a.modified)), + }, + } + } + Ok(files) + } +} + +// Local members + +/// Torrent file extension +const E: &str = "torrent"; + +/// Search keyword separators +const S: &[char] = &[ + '_', '-', ':', ';', ',', '(', ')', '[', ']', '/', '!', '?', ' ', // @TODO make optional +]; + +struct File { + modified: SystemTime, + path: PathBuf, +}