use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DownloadId(Uuid);
impl DownloadId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
pub fn as_uuid(&self) -> &Uuid {
&self.0
}
pub fn to_gid(&self) -> String {
hex::encode(&self.0.as_bytes()[0..8])
}
pub fn from_gid(gid: &str) -> Option<Self> {
if gid.len() != 16 {
return None;
}
let bytes = hex::decode(gid).ok()?;
if bytes.len() != 8 {
return None;
}
let mut uuid_bytes = [0u8; 16];
uuid_bytes[0..8].copy_from_slice(&bytes);
Some(Self(Uuid::from_bytes(uuid_bytes)))
}
pub fn matches_gid(&self, gid: &str) -> bool {
self.to_gid() == gid
}
}
impl Default for DownloadId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for DownloadId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_gid())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DownloadKind {
Http,
Torrent,
Magnet,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "lowercase")]
pub enum DownloadState {
Queued,
Connecting,
Downloading,
Seeding,
Paused,
Completed,
Error {
kind: String,
message: String,
retryable: bool,
},
}
impl DownloadState {
pub fn is_active(&self) -> bool {
matches!(self, Self::Downloading | Self::Seeding | Self::Connecting)
}
pub fn is_finished(&self) -> bool {
matches!(self, Self::Completed | Self::Error { .. })
}
pub fn to_aria2_status(&self) -> &'static str {
match self {
Self::Queued => "waiting",
Self::Connecting => "active",
Self::Downloading => "active",
Self::Seeding => "active",
Self::Paused => "paused",
Self::Completed => "complete",
Self::Error { .. } => "error",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DownloadProgress {
pub total_size: Option<u64>,
pub completed_size: u64,
pub download_speed: u64,
pub upload_speed: u64,
pub connections: u32,
pub seeders: u32,
pub peers: u32,
pub eta_seconds: Option<u64>,
}
impl DownloadProgress {
pub fn percentage(&self) -> f64 {
match self.total_size {
Some(total) if total > 0 => (self.completed_size as f64 / total as f64) * 100.0,
_ => 0.0,
}
}
}
mod hex {
pub fn encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn decode(s: &str) -> Result<Vec<u8>, ()> {
if s.len() % 2 != 0 {
return Err(());
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_gid_returns_16_char_hex_string() {
let id = DownloadId::new();
let gid = id.to_gid();
assert_eq!(gid.len(), 16);
assert!(gid.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn from_gid_round_trip_is_lossy() {
let id = DownloadId::new();
let gid = id.to_gid();
let reconstructed = DownloadId::from_gid(&gid).expect("valid GID");
assert_ne!(
id, reconstructed,
"round-trip should be lossy (upper 8 bytes zeroed)"
);
assert_eq!(gid, reconstructed.to_gid());
}
#[test]
fn matches_gid_works_without_round_trip() {
let id = DownloadId::new();
let gid = id.to_gid();
assert!(id.matches_gid(&gid));
let other = DownloadId::new();
assert!(!other.matches_gid(&gid));
}
#[test]
fn from_gid_rejects_invalid_input() {
assert!(DownloadId::from_gid("").is_none());
assert!(DownloadId::from_gid("abc").is_none());
assert!(DownloadId::from_gid("0123456789abcde").is_none());
assert!(DownloadId::from_gid("0123456789abcdef0").is_none());
assert!(DownloadId::from_gid("zzzzzzzzzzzzzzzz").is_none());
}
#[test]
fn from_gid_accepts_valid_input() {
let id = DownloadId::from_gid("0123456789abcdef");
assert!(id.is_some());
assert_eq!(id.unwrap().to_gid(), "0123456789abcdef");
}
#[test]
fn matches_gid_rejects_wrong_length() {
let id = DownloadId::new();
assert!(!id.matches_gid("abc"));
assert!(!id.matches_gid(""));
}
}