add transport feature: meta-share transport-plugin contract, client, harness, focus replica, testkit
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user