264 lines
8.8 KiB
Rust
264 lines
8.8 KiB
Rust
//! BitTorrent-specific half of the contract: the torrent plugin's extra routes,
|
|
//! and the magnet helpers both sides use.
|
|
//!
|
|
//! Extra routes (all under `/torrent/`):
|
|
//!
|
|
//! - `GET /torrent/inventory` → [`Inventory`] — every managed torrent,
|
|
//! with have-bytes, per-file progress and live stats. One call per eviction
|
|
//! pass / dashboard poll instead of one per row.
|
|
//! - `GET /torrent/status/:cid` → `Option<`[`LiveStats`]`>` — peeks only,
|
|
//! never starts the session (meta-watch polls it while connecting).
|
|
//! - `GET /torrent/peers/:cid?budget_ms=&cap=` → [`PeerCount`] — query-only
|
|
//! mainline-DHT population for the availability probe.
|
|
//! - `POST /torrent/confirm` ← [`ConfirmRequest`] → [`ConfirmResponse`]
|
|
//! — metadata-only reachability probe (no torrent kept, no seed row).
|
|
//! - `PUT /torrent/commits` ← [`Commits`] — the watch-commit intents.
|
|
//! The hull owns the intent (`seeds.json`); the plugin's supervisor owns the
|
|
//! fill (disk floor, concurrency cap, stall hysteresis).
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Live transfer stats for one torrent. Serialises exactly like meta-share's
|
|
/// former `bt::SeedLiveStats`, which `/api/file/:cid/status` and `/api/seeds`
|
|
/// still emit.
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
pub struct LiveStats {
|
|
/// librqbit torrent state: `live` | `paused` | `initializing` | `error`.
|
|
pub state: String,
|
|
pub down_bps: u64,
|
|
pub up_bps: u64,
|
|
pub uploaded_bytes: u64,
|
|
pub downloaded_bytes: u64,
|
|
pub total_bytes: u64,
|
|
pub ratio: f64,
|
|
pub peers_live: usize,
|
|
pub peers_seen: usize,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
pub struct FileProgress {
|
|
pub index: usize,
|
|
/// File length from the torrent metadata.
|
|
pub len: u64,
|
|
/// Bytes of it on disk.
|
|
pub have: u64,
|
|
}
|
|
|
|
impl FileProgress {
|
|
pub fn complete(&self) -> bool {
|
|
self.have >= self.len
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
pub struct TorrentInfo {
|
|
/// Lowercase hex v1 infohash.
|
|
pub infohash: String,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
/// librqbit have-bytes — the eviction size of the unit.
|
|
pub have_bytes: u64,
|
|
pub streaming_only: bool,
|
|
/// Metadata resolved (`files` is empty until it is).
|
|
pub metadata: bool,
|
|
#[serde(default)]
|
|
pub files: Vec<FileProgress>,
|
|
pub stats: LiveStats,
|
|
}
|
|
|
|
impl TorrentInfo {
|
|
/// Is file `index` fully downloaded? `None` when unknowable yet.
|
|
pub fn file_complete(&self, index: usize) -> Option<bool> {
|
|
self.files.iter().find(|f| f.index == index).map(FileProgress::complete)
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
pub struct Inventory {
|
|
/// Is the librqbit session up? When it isn't, `torrents` is empty and the
|
|
/// hull falls back to row lengths (the session is lazy on purpose).
|
|
pub session_up: bool,
|
|
/// Does the session directory hold persisted torrents?
|
|
pub persisted: bool,
|
|
pub torrents: Vec<TorrentInfo>,
|
|
}
|
|
|
|
impl Inventory {
|
|
pub fn get(&self, infohash_hex: &str) -> Option<&TorrentInfo> {
|
|
self.torrents.iter().find(|t| t.infohash == infohash_hex)
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub struct PeerCount {
|
|
/// Distinct DHT peers seen; `None` when the DHT couldn't be queried.
|
|
pub peers: Option<usize>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
pub struct ConfirmRequest {
|
|
pub magnet: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default)]
|
|
pub struct ConfirmResponse {
|
|
/// Length of file 0.
|
|
pub len: u64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct CommitIntent {
|
|
pub cid: String,
|
|
/// The seed row's `added_at` — the supervisor's recency order.
|
|
pub added_at: u64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct Commits {
|
|
pub intents: Vec<CommitIntent>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Magnet helpers (moved verbatim from meta-share `bt.rs`)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A bare-infohash magnet: DHT resolves peers from the infohash alone.
|
|
pub fn build_magnet(infohash20: &[u8; 20]) -> String {
|
|
format!("magnet:?xt=urn:btih:{}", super::cid::to_hex(infohash20))
|
|
}
|
|
|
|
/// High-uptime open trackers appended to every magnet before librqbit sees it,
|
|
/// so a trackerless indexer magnet doesn't leave peer discovery to DHT alone.
|
|
pub const DEFAULT_TRACKERS: &[&str] = &[
|
|
"udp://tracker.opentrackr.org:1337/announce",
|
|
"udp://open.stealth.si:80/announce",
|
|
"udp://tracker.openbittorrent.com:6969/announce",
|
|
"udp://exodus.desync.com:6969/announce",
|
|
"udp://tracker.torrent.eu.org:451/announce",
|
|
"udp://open.demonii.com:1337/announce",
|
|
"http://nyaa.tracker.wf:7777/announce",
|
|
];
|
|
|
|
fn encode_tr(url: &str) -> String {
|
|
let mut out = String::with_capacity(url.len() * 3);
|
|
for b in url.bytes() {
|
|
match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
|
out.push(b as char)
|
|
}
|
|
_ => out.push_str(&format!("%{b:02X}")),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Append [`DEFAULT_TRACKERS`] to `magnet`, skipping any already present. A
|
|
/// non-`magnet:` string is returned unchanged.
|
|
pub fn with_default_trackers(mut magnet: String) -> String {
|
|
if !magnet.starts_with("magnet:") {
|
|
return magnet;
|
|
}
|
|
for tr in DEFAULT_TRACKERS {
|
|
let enc = encode_tr(tr);
|
|
if magnet.contains(&enc) || magnet.contains(tr) {
|
|
continue;
|
|
}
|
|
magnet.push_str("&tr=");
|
|
magnet.push_str(&enc);
|
|
}
|
|
magnet
|
|
}
|
|
|
|
fn decode_tr(s: &str) -> String {
|
|
let bytes = s.as_bytes();
|
|
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
|
let hi = (bytes[i + 1] as char).to_digit(16);
|
|
let lo = (bytes[i + 2] as char).to_digit(16);
|
|
if let (Some(hi), Some(lo)) = (hi, lo) {
|
|
out.push((hi * 16 + lo) as u8);
|
|
i += 3;
|
|
continue;
|
|
}
|
|
}
|
|
out.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
String::from_utf8_lossy(&out).into_owned()
|
|
}
|
|
|
|
/// The decoded, de-duplicated `tr=` announce URLs of a magnet.
|
|
pub fn parse_trackers(magnet: &str) -> Vec<String> {
|
|
if !magnet.starts_with("magnet:") {
|
|
return Vec::new();
|
|
}
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut out = Vec::new();
|
|
let query = magnet.splitn(2, '?').nth(1).unwrap_or("");
|
|
for pair in query.split('&') {
|
|
if let Some(val) = pair.strip_prefix("tr=") {
|
|
let url = decode_tr(val);
|
|
if !url.is_empty() && seen.insert(url.clone()) {
|
|
out.push(url);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn build_magnet_includes_infohash() {
|
|
assert_eq!(
|
|
build_magnet(&[0xab; 20]),
|
|
"magnet:?xt=urn:btih:abababababababababababababababababababab"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn with_default_trackers_appends_to_trackerless_magnet() {
|
|
let bare = "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Foo".to_string();
|
|
let out = with_default_trackers(bare);
|
|
assert!(out.contains("&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce"));
|
|
assert!(out.contains("&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce"));
|
|
assert!(out.contains("&dn=Foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn with_default_trackers_dedups_existing() {
|
|
let pre = "magnet:?xt=urn:btih:abababababababababababababababababababab\
|
|
&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce"
|
|
.to_string();
|
|
let out = with_default_trackers(pre);
|
|
assert_eq!(out.matches("tracker.opentrackr.org%3A1337").count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn with_default_trackers_leaves_non_magnet_untouched() {
|
|
assert_eq!(with_default_trackers("not-a-magnet".to_string()), "not-a-magnet");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_trackers_round_trips_the_defaults() {
|
|
let m = with_default_trackers(build_magnet(&[1; 20]));
|
|
assert_eq!(parse_trackers(&m), DEFAULT_TRACKERS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
|
assert!(parse_trackers("not-a-magnet").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn live_stats_serialise_with_the_legacy_field_names() {
|
|
let v = serde_json::to_value(LiveStats::default()).unwrap();
|
|
for k in [
|
|
"state", "down_bps", "up_bps", "uploaded_bytes", "downloaded_bytes",
|
|
"total_bytes", "ratio", "peers_live", "peers_seen",
|
|
] {
|
|
assert!(v.get(k).is_some(), "missing {k}");
|
|
}
|
|
}
|
|
}
|