implement btracker-scrape API, use shared State Arc for initiated components

This commit is contained in:
yggverse 2025-09-09 11:17:37 +03:00
parent 64ad6f01a8
commit d9edf977c7
3 changed files with 101 additions and 41 deletions

View file

@ -12,6 +12,7 @@ repository = "https://github.com/YGGverse/btracker-gemini"
[dependencies] [dependencies]
anyhow = "1.0.95" anyhow = "1.0.95"
btracker-fs = { version = "0.2.0", features = ["public"] } btracker-fs = { version = "0.2.0", features = ["public"] }
btracker-scrape = "0.1.0"
chrono = "^0.4.20" chrono = "^0.4.20"
clap = { version = "4.5.30", features = ["derive"] } clap = { version = "4.5.30", features = ["derive"] }
log = "0.4.28" log = "0.4.28"
@ -27,4 +28,5 @@ regex = "1.11.2"
# development # development
[patch.crates-io] [patch.crates-io]
btracker-fs = { git = "https://github.com/YGGverse/btracker-fs.git" } btracker-fs = { git = "https://github.com/YGGverse/btracker-fs.git" }
btracker-scrape = { git = "https://github.com/YGGverse/btracker-scrape.git" }
# btracker-fs = { path = "../btracker-fs" } # btracker-fs = { path = "../btracker-fs" }

View file

@ -1,6 +1,6 @@
use clap::Parser; use clap::Parser;
use std::{ use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4}, net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6},
path::PathBuf, path::PathBuf,
}; };
use url::Url; use url::Url;
@ -47,4 +47,13 @@ pub struct Config {
/// Default index capacity /// Default index capacity
#[arg(short, long, default_value_t = 1000)] #[arg(short, long, default_value_t = 1000)]
pub capacity: usize, pub capacity: usize,
/// Bind scrape UDP server
///
/// * requires `tracker` value(s) to enable scrape features
#[arg(long, default_values_t = vec![
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)),
SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0))
])]
pub udp: Vec<SocketAddr>,
} }

View file

@ -4,6 +4,7 @@ mod route;
use anyhow::Result; use anyhow::Result;
use btracker_fs::public::{Order, Public, Sort, Torrent}; use btracker_fs::public::{Order, Public, Sort, Torrent};
use btracker_scrape::*;
use config::Config; use config::Config;
use librqbit_core::torrent_metainfo::{TorrentMetaV1Owned, torrent_from_bytes}; use librqbit_core::torrent_metainfo::{TorrentMetaV1Owned, torrent_from_bytes};
use log::*; use log::*;
@ -11,7 +12,7 @@ use native_tls::{HandshakeError, Identity, TlsAcceptor, TlsStream};
use std::{ use std::{
fs::File, fs::File,
io::{Read, Write}, io::{Read, Write},
net::{SocketAddr, TcpListener, TcpStream}, net::{IpAddr, SocketAddr, TcpListener, TcpStream},
path::PathBuf, path::PathBuf,
sync::Arc, sync::Arc,
thread, thread,
@ -35,8 +36,43 @@ fn main() -> Result<()> {
.init() .init()
} }
let config = Arc::new(Config::parse()); let config = Config::parse();
let public = Arc::new(Public::init(&config.storage, config.limit, config.capacity).unwrap()); let state = Arc::new(State {
public: Public::init(&config.storage, config.limit, config.capacity).unwrap(),
scrape: Scrape::init(
config
.tracker
.as_ref()
.map(|u| {
u.iter()
.map(|url| {
use std::str::FromStr;
if url.scheme() == "tcp" {
todo!("TCP scrape is not implemented")
}
if url.scheme() != "udp" {
todo!("Scheme `{}` is not supported", url.scheme())
}
SocketAddr::new(
IpAddr::from_str(
url.host_str()
.expect("Required valid host value")
.trim_start_matches('[')
.trim_end_matches(']'),
)
.unwrap(),
url.port().expect("Required valid port value"),
)
})
.collect()
})
.map(|a| (config.udp, a)),
),
format_date: config.format_date,
name: config.name,
description: config.description,
tracker: config.tracker,
});
// https://geminiprotocol.net/docs/protocol-specification.gmi#the-use-of-tls // https://geminiprotocol.net/docs/protocol-specification.gmi#the-use-of-tls
let acceptor = TlsAcceptor::new(Identity::from_pkcs12( let acceptor = TlsAcceptor::new(Identity::from_pkcs12(
@ -48,7 +84,7 @@ fn main() -> Result<()> {
&config.password, &config.password,
)?)?; )?)?;
let listener = TcpListener::bind(&config.bind)?; let listener = TcpListener::bind(config.bind)?;
info!("Server started on `{}`", config.bind); info!("Server started on `{}`", config.bind);
@ -56,11 +92,10 @@ fn main() -> Result<()> {
match stream { match stream {
Ok(stream) => { Ok(stream) => {
thread::spawn({ thread::spawn({
let config = config.clone(); let state = state.clone();
let public = public.clone();
let peer = stream.peer_addr()?; let peer = stream.peer_addr()?;
let connection = acceptor.accept(stream); let connection = acceptor.accept(stream);
move || handle(config, public, peer, connection) move || handle(state, peer, connection)
}); });
} }
Err(e) => error!("{e}"), Err(e) => error!("{e}"),
@ -70,8 +105,7 @@ fn main() -> Result<()> {
} }
fn handle( fn handle(
config: Arc<Config>, state: Arc<State>,
public: Arc<Public>,
peer: SocketAddr, peer: SocketAddr,
connection: Result<TlsStream<TcpStream>, HandshakeError<TcpStream>>, connection: Result<TlsStream<TcpStream>, HandshakeError<TcpStream>>,
) { ) {
@ -110,7 +144,7 @@ fn handle(
if header_buffer.last().is_some_and(|&b| b == b'\n') { if header_buffer.last().is_some_and(|&b| b == b'\n') {
// header bytes contain valid Gemini **request** // header bytes contain valid Gemini **request**
if let Ok(request) = request::Gemini::from_bytes(&header_buffer) { if let Ok(request) = request::Gemini::from_bytes(&header_buffer) {
return response(request, &config, &public, &peer, &mut stream); return response(request, &state, &peer, &mut stream);
} }
// header bytes received but yet could not be parsed, // header bytes received but yet could not be parsed,
@ -148,8 +182,7 @@ fn handle(
fn response( fn response(
request: titanite::request::Gemini, request: titanite::request::Gemini,
config: &Config, state: &State,
public: &Public,
peer: &SocketAddr, peer: &SocketAddr,
stream: &mut TlsStream<TcpStream>, stream: &mut TlsStream<TcpStream>,
) { ) {
@ -157,7 +190,7 @@ fn response(
use titanite::response::*; use titanite::response::*;
debug!("Incoming request from `{peer}` to `{}`", request.url.path()); debug!("Incoming request from `{peer}` to `{}`", request.url.path());
send( send(
&match Route::from_url(&request.url, public) { &match Route::from_url(&request.url, &state.public) {
Route::File(ref path) => success::Default { Route::File(ref path) => success::Default {
data: &std::fs::read(path).unwrap(), data: &std::fs::read(path).unwrap(),
meta: success::default::Meta { meta: success::default::Meta {
@ -174,6 +207,8 @@ fn response(
"image/webp" "image/webp"
} else if e == "txt" || e == "log" { } else if e == "txt" || e == "log" {
"text/plain" "text/plain"
} else if e == "gemini" || e == "gmi" {
"text/gemini"
} else { } else {
todo!() todo!()
} }
@ -184,7 +219,7 @@ fn response(
}, },
} }
.into_bytes(), .into_bytes(),
Route::List { page, keyword } => match list(config, public, keyword.as_deref(), page) { Route::List { page, keyword } => match list(state, keyword.as_deref(), page) {
Ok(data) => success::Default { Ok(data) => success::Default {
data: data.as_bytes(), data: data.as_bytes(),
meta: success::default::Meta { meta: success::default::Meta {
@ -204,8 +239,8 @@ fn response(
message: Some("Keyword, file, hash...".into()), message: Some("Keyword, file, hash...".into()),
}) })
.into_bytes(), .into_bytes(),
Route::Info(id) => match public.torrent(id) { Route::Info(id) => match state.public.torrent(id) {
Some(torrent) => match info(config, public, torrent) { Some(torrent) => match info(state, torrent) {
Ok(data) => success::Default { Ok(data) => success::Default {
data: data.as_bytes(), data: data.as_bytes(),
meta: success::default::Meta { meta: success::default::Meta {
@ -267,12 +302,7 @@ fn send(data: &[u8], stream: &mut TlsStream<TcpStream>, callback: impl FnOnce(Re
})()); })());
} }
fn list( fn list(state: &State, keyword: Option<&str>, page: Option<usize>) -> Result<String> {
config: &Config,
public: &Public,
keyword: Option<&str>,
page: Option<usize>,
) -> Result<String> {
use plurify::Plurify; use plurify::Plurify;
/// format search keyword as the pagination query /// format search keyword as the pagination query
@ -280,22 +310,22 @@ fn list(
keyword.map(|k| format!("?{}", k)).unwrap_or_default() keyword.map(|k| format!("?{}", k)).unwrap_or_default()
} }
let (total, torrents) = public.torrents( let (total, torrents) = state.public.torrents(
keyword, keyword,
Some((Sort::Modified, Order::Desc)), Some((Sort::Modified, Order::Desc)),
page.map(|p| if p > 0 { p - 1 } else { p } * public.default_limit), page.map(|p| if p > 0 { p - 1 } else { p } * state.public.default_limit),
Some(public.default_limit), Some(state.public.default_limit),
)?; )?;
let mut b = Vec::new(); let mut b = Vec::new();
b.push(format!("# {}\n", config.name)); b.push(format!("# {}\n", state.name));
if let Some(ref description) = config.description { if let Some(ref description) = state.description {
b.push(format!("{description}\n")); b.push(format!("{description}\n"));
} }
if let Some(ref trackers) = config.tracker { if let Some(ref trackers) = state.tracker {
//b.push(format!("## Connect\n")); //b.push(format!("## Connect\n"));
b.push("```".into()); b.push("```".into());
for tracker in trackers { for tracker in trackers {
@ -321,11 +351,16 @@ fn list(
.unwrap_or_default() .unwrap_or_default()
)); ));
b.push(format!( b.push(format!(
"{} • {} • {}\n", "{} • {} • {}{}\n",
torrent.time.format(&config.format_date), torrent.time.format(&state.format_date),
format::total(&i), format::total(&i),
format::files(&i), format::files(&i),
)) state
.scrape
.scrape(i.info_hash.0)
.map(|s| format!("{}{}{}", s.seeders, s.peers, s.leechers))
.unwrap_or_default()
));
} }
} }
@ -334,11 +369,11 @@ fn list(
b.push(format!( b.push(format!(
"Page {} / {} ({total} {} total)\n", "Page {} / {} ({total} {} total)\n",
page.unwrap_or(1), page.unwrap_or(1),
(total as f64 / public.default_limit as f64).ceil(), (total as f64 / state.public.default_limit as f64).ceil(),
total.plurify(&["torrent", "torrents", "torrents"]) total.plurify(&["torrent", "torrents", "torrents"])
)); ));
if page.unwrap_or(1) * public.default_limit < total { if page.unwrap_or(1) * state.public.default_limit < total {
b.push(format!( b.push(format!(
"=> /{}{} Next", "=> /{}{} Next",
page.map_or(2, |p| p + 1), page.map_or(2, |p| p + 1),
@ -363,7 +398,7 @@ fn list(
Ok(b.join("\n")) Ok(b.join("\n"))
} }
fn info(config: &Config, public: &Public, torrent: Torrent) -> Result<String> { fn info(state: &State, torrent: Torrent) -> Result<String> {
struct File { struct File {
path: Option<PathBuf>, path: Option<PathBuf>,
length: u64, length: u64,
@ -387,25 +422,30 @@ fn info(config: &Config, public: &Public, torrent: Torrent) -> Result<String> {
.name .name
.as_ref() .as_ref()
.map(|n| n.to_string()) .map(|n| n.to_string())
.unwrap_or(config.name.clone()) .unwrap_or(state.name.clone())
)); ));
b.push(format!( b.push(format!(
"{} • {} • {}\n", "{} • {} • {}{}\n",
torrent.time.format(&config.format_date), torrent.time.format(&state.format_date),
format::total(&i), format::total(&i),
format::files(&i), format::files(&i),
state
.scrape
.scrape(i.info_hash.0)
.map(|s| format!("{}{}{}", s.seeders, s.peers, s.leechers))
.unwrap_or_default()
)); ));
b.push(format!( b.push(format!(
"=> {} Magnet\n", "=> {} Magnet\n",
format::magnet(&i, config.tracker.as_ref()) format::magnet(&i, state.tracker.as_ref())
)); ));
if let Some(files) = i.info.files.map(|files| { if let Some(files) = i.info.files.map(|files| {
let mut b = Vec::with_capacity(files.len()); let mut b = Vec::with_capacity(files.len());
for f in files { for f in files {
let mut p = std::path::PathBuf::new(); let mut p = PathBuf::new();
b.push(File { b.push(File {
length: f.length, length: f.length,
path: match f.full_path(&mut p) { path: match f.full_path(&mut p) {
@ -423,7 +463,7 @@ fn info(config: &Config, public: &Public, torrent: Torrent) -> Result<String> {
b.push("## Files\n".into()); b.push("## Files\n".into());
for file in files { for file in files {
let p = file.path(); let p = file.path();
b.push(match public.href(&i.info_hash.as_string(), &p) { b.push(match state.public.href(&i.info_hash.as_string(), &p) {
Some(href) => format!( Some(href) => format!(
"=> {} {} ({})", "=> {} {} ({})",
urlencoding::encode(&href), urlencoding::encode(&href),
@ -437,3 +477,12 @@ fn info(config: &Config, public: &Public, torrent: Torrent) -> Result<String> {
Ok(b.join("\n")) Ok(b.join("\n"))
} }
struct State {
description: Option<String>,
format_date: String,
name: String,
public: Public,
scrape: Scrape,
tracker: Option<Vec<url::Url>>,
}