implement clickable preload files, rename static to public, rename preload config argument to public, rename Storage to Public

This commit is contained in:
yggverse 2025-08-09 23:11:46 +03:00
parent bff6b209c9
commit 6b149ba1b9
13 changed files with 77 additions and 51 deletions

View file

@ -43,12 +43,13 @@ See the [Wiki](https://github.com/YGGverse/btracker/wiki/Screenshots) page
## Usage ## Usage
``` bash ``` bash
btracker --preload=/path/to/aquatic-crawler/preload\ btracker --public=/path/to/aquatic-crawler/preload\
--scrape=udp://127.0.0.1:6969\ --scrape=udp://127.0.0.1:6969\
--tracker=udp://[302:68d0:f0d5:b88d::fdb]:6969\ --tracker=udp://[302:68d0:f0d5:b88d::fdb]:6969\
--tracker=udp://tracker.ygg:6969 --tracker=udp://tracker.ygg:6969
``` ```
* The `--preload` argument specifies the location of the crawled torrents (see [aquatic-crawler](https://github.com/yggverse/aquatic-crawler)) * The `--public` argument specifies the location of the crawled torrents (see [aquatic-crawler](https://github.com/yggverse/aquatic-crawler))
* make sure this location also contains a copy (or symlink) of the `/public` files from this crate (see the [Rocket deploying specification](https://rocket.rs/guide/v0.5/deploying/) for details)
* The `--scrape` argument is optional and enables statistics for peers, seeders, and leechers * The `--scrape` argument is optional and enables statistics for peers, seeders, and leechers
* it is recommended to use the local address for faster performance * it is recommended to use the local address for faster performance
* this argument supports multiple definitions for both the IPv4 and IPv6 protocols, parsed from the URL value * this argument supports multiple definitions for both the IPv4 and IPv6 protocols, parsed from the URL value

3
public/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/*
!.gitignore
!/theme

View file

@ -45,6 +45,7 @@ h1, h2, h3, h4, h5 {
h1, h2 { h1, h2 {
font-size: 14px; font-size: 14px;
padding: 0 8px;
} }
table { table {
@ -108,7 +109,7 @@ main > a {
float: right; float: right;
margin-left: 8px; margin-left: 8px;
opacity: .96; opacity: .96;
padding: 8px; padding: 8px 12px;
} }
/* item row */ /* item row */
@ -129,7 +130,7 @@ main > div > div {
border-top: 1px solid var(--separator); border-top: 1px solid var(--separator);
margin-top: 16px; margin-top: 16px;
overflow: hidden; overflow: hidden;
padding-top: 16px; padding: 16px 8px 0;
} }
main > div > div > ul { main > div > div > ul {

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 335 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 279 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 281 B

After

Width:  |  Height:  |  Size: 281 B

Before After
Before After

View file

@ -8,9 +8,13 @@ use url::Url;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
pub struct Config { pub struct Config {
/// Path to the [aquatic-crawler](https://github.com/yggverse/aquatic-crawler) preload files /// Path to the `public` directory
///
/// This location must contain:
/// * the default or custom `/public/*` files (see the [Rocket deploying specification](https://rocket.rs/guide/v0.5/deploying/))
/// * torrents with files collected by the [aquatic-crawler](https://github.com/yggverse/aquatic-crawler)
#[arg(long)] #[arg(long)]
pub preload: PathBuf, pub public: PathBuf,
/// Server name /// Server name
#[arg(long, default_value_t = String::from("βtracker"))] #[arg(long, default_value_t = String::from("βtracker"))]
@ -34,15 +38,11 @@ pub struct Config {
#[arg(long, default_value_t = String::from("%d/%m/%Y %H:%M"))] #[arg(long, default_value_t = String::from("%d/%m/%Y %H:%M"))]
pub format_time: String, pub format_time: String,
/// Path to the framework assets
#[arg(long, default_value_t = String::from("./static"))]
pub statics: String,
/// Default listing limit /// Default listing limit
#[arg(long, default_value_t = 20)] #[arg(long, default_value_t = 20)]
pub list_limit: usize, pub list_limit: usize,
/// Default capacity (estimated torrents in the `preload` directory) /// Default capacity (estimated torrents in the `public` directory)
#[arg(long, default_value_t = 1000)] #[arg(long, default_value_t = 1000)]
pub capacity: usize, pub capacity: usize,

View file

@ -60,7 +60,7 @@ impl Feed {
pub fn push(&self, buffer: &mut String, torrent: Torrent) { pub fn push(&self, buffer: &mut String, torrent: Torrent) {
buffer.push_str(&format!( buffer.push_str(&format!(
"<item><guid>{}</guid><title>{}</title><link>{}</link>", "<item><guid>{}</guid><title>{}</title><link>{}</link>",
&torrent.info_hash, torrent.info_hash,
escape( escape(
torrent torrent
.name .name

View file

@ -4,14 +4,15 @@ extern crate rocket;
mod config; mod config;
mod feed; mod feed;
mod meta; mod meta;
mod public;
mod scraper; mod scraper;
mod storage;
mod torrent; mod torrent;
use config::Config; use config::Config;
use feed::Feed; use feed::Feed;
use meta::Meta; use meta::Meta;
use plurify::Plurify; use plurify::Plurify;
use public::{Order, Public, Sort};
use rocket::{ use rocket::{
State, State,
http::Status, http::Status,
@ -21,14 +22,13 @@ use rocket::{
use rocket_dyn_templates::{Template, context}; use rocket_dyn_templates::{Template, context};
use scraper::{Scrape, Scraper}; use scraper::{Scrape, Scraper};
use std::str::FromStr; use std::str::FromStr;
use storage::{Order, Sort, Storage};
use torrent::Torrent; use torrent::Torrent;
#[get("/?<page>")] #[get("/?<page>")]
fn index( fn index(
page: Option<usize>, page: Option<usize>,
scraper: &State<Scraper>, scraper: &State<Scraper>,
storage: &State<Storage>, public: &State<Public>,
meta: &State<Meta>, meta: &State<Meta>,
) -> Result<Template, Custom<String>> { ) -> Result<Template, Custom<String>> {
#[derive(Serialize)] #[derive(Serialize)]
@ -42,14 +42,14 @@ fn index(
size: String, size: String,
torrent: Torrent, torrent: Torrent,
} }
let (total, torrents) = storage let (total, torrents) = public
.torrents( .torrents(
Some((Sort::Modified, Order::Desc)), Some((Sort::Modified, Order::Desc)),
page.map(|p| if p > 0 { p - 1 } else { p } * storage.default_limit), page.map(|p| if p > 0 { p - 1 } else { p } * public.default_limit),
Some(storage.default_limit), Some(public.default_limit),
) )
.map_err(|e| { .map_err(|e| {
error!("Torrents storage read error: `{e}`"); error!("Torrents public storage read error: `{e}`");
Custom(Status::InternalServerError, E.to_string()) Custom(Status::InternalServerError, E.to_string())
})?; })?;
Ok(Template::render( Ok(Template::render(
@ -57,11 +57,11 @@ fn index(
context! { context! {
meta: meta.inner(), meta: meta.inner(),
back: page.map(|p| uri!(index(if p > 2 { Some(p - 1) } else { None }))), back: page.map(|p| uri!(index(if p > 2 { Some(p - 1) } else { None }))),
next: if page.unwrap_or(1) * storage.default_limit >= total { None } next: if page.unwrap_or(1) * public.default_limit >= total { None }
else { Some(uri!(index(Some(page.map_or(2, |p| p + 1))))) }, else { Some(uri!(index(Some(page.map_or(2, |p| p + 1))))) },
rows: torrents rows: torrents
.into_iter() .into_iter()
.filter_map(|t| match Torrent::from_storage(&t.bytes, t.time) { .filter_map(|t| match Torrent::from_public(&t.bytes, t.time) {
Ok(torrent) => Some(R { Ok(torrent) => Some(R {
created: torrent.creation_date.map(|t| t.format(&meta.format_time).to_string()), created: torrent.creation_date.map(|t| t.format(&meta.format_time).to_string()),
files: torrent.files(), files: torrent.files(),
@ -80,7 +80,7 @@ fn index(
pagination_totals: format!( pagination_totals: format!(
"Page {} / {} ({total} {} total)", "Page {} / {} ({total} {} total)",
page.unwrap_or(1), page.unwrap_or(1),
(total as f64 / storage.default_limit as f64).ceil(), (total as f64 / public.default_limit as f64).ceil(),
total.plurify(&["torrent", "torrents", "torrents"]) total.plurify(&["torrent", "torrents", "torrents"])
) )
}, },
@ -90,11 +90,11 @@ fn index(
#[get("/<info_hash>")] #[get("/<info_hash>")]
fn info( fn info(
info_hash: &str, info_hash: &str,
storage: &State<Storage>, public: &State<Public>,
scraper: &State<Scraper>, scraper: &State<Scraper>,
meta: &State<Meta>, meta: &State<Meta>,
) -> Result<Template, Custom<String>> { ) -> Result<Template, Custom<String>> {
match storage.torrent(librqbit_core::Id20::from_str(info_hash).map_err(|e| { match public.torrent(librqbit_core::Id20::from_str(info_hash).map_err(|e| {
warn!("Torrent info-hash parse error: `{e}`"); warn!("Torrent info-hash parse error: `{e}`");
Custom(Status::BadRequest, Status::BadRequest.to_string()) Custom(Status::BadRequest, Status::BadRequest.to_string())
})?) { })?) {
@ -102,10 +102,11 @@ fn info(
#[derive(Serialize)] #[derive(Serialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
struct F { struct F {
href: Option<String>,
path: String, path: String,
size: String, size: String,
} }
let torrent = Torrent::from_storage(&t.bytes, t.time).map_err(|e| { let torrent = Torrent::from_public(&t.bytes, t.time).map_err(|e| {
error!("Torrent parse error: `{e}`"); error!("Torrent parse error: `{e}`");
Custom(Status::InternalServerError, E.to_string()) Custom(Status::InternalServerError, E.to_string())
})?; })?;
@ -117,15 +118,19 @@ fn info(
files_total: torrent.files(), files_total: torrent.files(),
files_list: torrent.files.as_ref().map(|f| { files_list: torrent.files.as_ref().map(|f| {
f.iter() f.iter()
.map(|f| F { .map(|f| {
path: f.path(), let p = f.path();
F {
href: public.href(&torrent.info_hash, &p),
path: p,
size: f.size(), size: f.size(),
}
}) })
.collect::<Vec<F>>() .collect::<Vec<F>>()
}), }),
indexed: torrent.time.format(&meta.format_time).to_string(), indexed: torrent.time.format(&meta.format_time).to_string(),
magnet: torrent.magnet(meta.trackers.as_ref()), magnet: torrent.magnet(meta.trackers.as_ref()),
scrape: scraper.scrape(info_hash), scrape: scraper.scrape(&torrent.info_hash),
size: torrent.size(), size: torrent.size(),
torrent torrent
}, },
@ -136,23 +141,23 @@ fn info(
} }
#[get("/rss")] #[get("/rss")]
fn rss(feed: &State<Feed>, storage: &State<Storage>) -> Result<RawXml<String>, Custom<String>> { fn rss(feed: &State<Feed>, public: &State<Public>) -> Result<RawXml<String>, Custom<String>> {
let mut b = feed.transaction(1024); // @TODO let mut b = feed.transaction(1024); // @TODO
for t in storage for t in public
.torrents( .torrents(
Some((Sort::Modified, Order::Desc)), Some((Sort::Modified, Order::Desc)),
None, None,
Some(storage.default_limit), Some(public.default_limit),
) )
.map_err(|e| { .map_err(|e| {
error!("Torrent storage read error: `{e}`"); error!("Torrent public storage read error: `{e}`");
Custom(Status::InternalServerError, E.to_string()) Custom(Status::InternalServerError, E.to_string())
})? })?
.1 .1
{ {
feed.push( feed.push(
&mut b, &mut b,
Torrent::from_storage(&t.bytes, t.time).map_err(|e| { Torrent::from_public(&t.bytes, t.time).map_err(|e| {
error!("Torrent parse error: `{e}`"); error!("Torrent parse error: `{e}`");
Custom(Status::InternalServerError, E.to_string()) Custom(Status::InternalServerError, E.to_string())
})?, })?,
@ -199,7 +204,6 @@ fn rocket() -> _ {
}) })
.map(|a| (config.udp, a)), .map(|a| (config.udp, a)),
); );
let storage = Storage::init(config.preload, config.list_limit, config.capacity).unwrap();
rocket::build() rocket::build()
.attach(Template::fairing()) .attach(Template::fairing())
.configure(rocket::Config { .configure(rocket::Config {
@ -213,7 +217,7 @@ fn rocket() -> _ {
}) })
.manage(feed) .manage(feed)
.manage(scraper) .manage(scraper)
.manage(storage) .manage(Public::init(config.public.clone(), config.list_limit, config.capacity).unwrap())
.manage(Meta { .manage(Meta {
canonical: config.canonical_url, canonical: config.canonical_url,
description: config.description, description: config.description,
@ -222,7 +226,7 @@ fn rocket() -> _ {
trackers: config.tracker, trackers: config.tracker,
version: env!("CARGO_PKG_VERSION").into(), version: env!("CARGO_PKG_VERSION").into(),
}) })
.mount("/", rocket::fs::FileServer::from(config.statics)) .mount("/", rocket::fs::FileServer::from(config.public))
.mount("/", routes![index, info, rss]) .mount("/", routes![index, info, rss])
} }

View file

@ -25,13 +25,13 @@ pub struct Torrent {
pub time: DateTime<Utc>, pub time: DateTime<Utc>,
} }
pub struct Storage { pub struct Public {
pub default_limit: usize, pub default_limit: usize,
default_capacity: usize, default_capacity: usize,
root: PathBuf, root: PathBuf,
} }
impl Storage { impl Public {
// Constructors // Constructors
pub fn init( pub fn init(
@ -40,7 +40,7 @@ impl Storage {
default_capacity: usize, default_capacity: usize,
) -> Result<Self, String> { ) -> Result<Self, String> {
if !root.is_dir() { if !root.is_dir() {
return Err("Storage root is not directory".into()); return Err("Public root is not directory".into());
} }
Ok(Self { Ok(Self {
default_limit, default_limit,
@ -88,6 +88,21 @@ impl Storage {
Ok((t, b)) Ok((t, b))
} }
pub fn href(&self, info_hash: &str, path: &str) -> Option<String> {
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 // Helpers
fn files(&self, sort_order: Option<(Sort, Order)>) -> Result<Vec<DirEntry>, Error> { fn files(&self, sort_order: Option<(Sort, Order)>) -> Result<Vec<DirEntry>, Error> {

View file

@ -2,7 +2,7 @@ mod file;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use file::File; use file::File;
use librqbit_core::{torrent_metainfo, torrent_metainfo::TorrentMetaV1Owned}; use librqbit_core::torrent_metainfo::{self, TorrentMetaV1Owned};
use rocket::serde::Serialize; use rocket::serde::Serialize;
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@ -25,7 +25,7 @@ pub struct Torrent {
} }
impl Torrent { impl Torrent {
pub fn from_storage(bytes: &[u8], time: DateTime<Utc>) -> Result<Self, String> { pub fn from_public(bytes: &[u8], time: DateTime<Utc>) -> Result<Self, String> {
let i: TorrentMetaV1Owned = let i: TorrentMetaV1Owned =
torrent_metainfo::torrent_from_bytes(bytes).map_err(|e| e.to_string())?; torrent_metainfo::torrent_from_bytes(bytes).map_err(|e| e.to_string())?;
Ok(Torrent { Ok(Torrent {
@ -84,11 +84,7 @@ impl Torrent {
} }
pub fn magnet(&self, trackers: Option<&Vec<url::Url>>) -> String { pub fn magnet(&self, trackers: Option<&Vec<url::Url>>) -> String {
let mut b = if self.info_hash.len() == 40 { let mut b = format!("magnet:?xt=urn:btih:{}", self.info_hash);
format!("magnet:?xt=urn:btih:{}", self.info_hash)
} else {
todo!("info-hash v2 yet not supported") // librqbit_core::hash_id::Id
};
if let Some(t) = trackers { if let Some(t) = trackers {
for tracker in t { for tracker in t {
b.push_str("&tr="); b.push_str("&tr=");

View file

@ -31,7 +31,13 @@
<tbody> <tbody>
{% for file in files_list %} {% for file in files_list %}
<tr> <tr>
<td>{{ file.path }}</td> <td>
{% if file.href %}
<a href="{{ file.href }}">{{ file.path }}</a>
{% else %}
{{ file.path }}
{% endif %}
</td>
<td>{{ file.size }}</td> <td>{{ file.size }}</td>
</tr> </tr>
{% endfor %} {% endfor %}