Skip to main content

gosh_dl/protocol/
types.rs

1//! Core protocol types
2//!
3//! Fundamental types used throughout the protocol.
4
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Unique identifier for a download
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct DownloadId(Uuid);
11
12impl DownloadId {
13    /// Create a new random download ID
14    pub fn new() -> Self {
15        Self(Uuid::new_v4())
16    }
17
18    /// Create from an existing UUID
19    pub fn from_uuid(uuid: Uuid) -> Self {
20        Self(uuid)
21    }
22
23    /// Get the underlying UUID
24    pub fn as_uuid(&self) -> &Uuid {
25        &self.0
26    }
27
28    /// Generate an aria2-compatible GID (16-char hex string).
29    ///
30    /// This is a **lossy** projection: only the first 8 bytes of the 16-byte
31    /// UUID are encoded, so the upper 8 bytes are discarded. Two distinct
32    /// `DownloadId` values that share the same first 8 bytes will produce
33    /// the same GID.
34    ///
35    /// Use [`matches_gid`](Self::matches_gid) to check whether a given GID
36    /// corresponds to this `DownloadId` without assuming a lossless round-trip.
37    pub fn to_gid(&self) -> String {
38        hex::encode(&self.0.as_bytes()[0..8])
39    }
40
41    /// Create a **new** `DownloadId` from an aria2-compatible GID string.
42    ///
43    /// Because a GID only carries 8 bytes of the original 16-byte UUID, the
44    /// returned `DownloadId` is **not** equal to whichever `DownloadId`
45    /// originally produced the GID (the upper 8 bytes are zero-filled).
46    /// In other words, `DownloadId::from_gid(id.to_gid()) != id` in the
47    /// general case.
48    ///
49    /// If you need to test whether an existing `DownloadId` matches a GID,
50    /// use [`matches_gid`](Self::matches_gid) instead.
51    pub fn from_gid(gid: &str) -> Option<Self> {
52        if gid.len() != 16 {
53            return None;
54        }
55        let bytes = hex::decode(gid).ok()?;
56        if bytes.len() != 8 {
57            return None;
58        }
59        // Create a UUID with the GID bytes + zeros for the upper half
60        let mut uuid_bytes = [0u8; 16];
61        uuid_bytes[0..8].copy_from_slice(&bytes);
62        Some(Self(Uuid::from_bytes(uuid_bytes)))
63    }
64
65    /// Check whether this `DownloadId`'s first 8 bytes match the given GID.
66    ///
67    /// This is the correct way to look up a `DownloadId` by GID without
68    /// relying on the lossy `from_gid` round-trip. It compares only the
69    /// bytes that `to_gid` encodes, so it works even when the upper 8 bytes
70    /// of two `DownloadId` values differ.
71    pub fn matches_gid(&self, gid: &str) -> bool {
72        self.to_gid() == gid
73    }
74}
75
76impl Default for DownloadId {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl std::fmt::Display for DownloadId {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "{}", self.to_gid())
85    }
86}
87
88/// Type of download
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum DownloadKind {
92    /// HTTP/HTTPS download
93    Http,
94    /// BitTorrent download from .torrent file
95    Torrent,
96    /// BitTorrent download from magnet URI
97    Magnet,
98}
99
100/// Current state of a download
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(tag = "state", rename_all = "lowercase")]
103pub enum DownloadState {
104    /// Waiting in queue
105    Queued,
106    /// Connecting to server/peers
107    Connecting,
108    /// Actively downloading
109    Downloading,
110    /// Seeding (torrent only)
111    Seeding,
112    /// Paused by user
113    Paused,
114    /// Successfully completed
115    Completed,
116    /// Failed with error
117    Error {
118        kind: String,
119        message: String,
120        retryable: bool,
121    },
122}
123
124impl DownloadState {
125    /// Check if download is active (downloading or seeding)
126    pub fn is_active(&self) -> bool {
127        matches!(self, Self::Downloading | Self::Seeding | Self::Connecting)
128    }
129
130    /// Check if download is finished (completed or error)
131    pub fn is_finished(&self) -> bool {
132        matches!(self, Self::Completed | Self::Error { .. })
133    }
134
135    /// Convert to aria2-compatible status string
136    pub fn to_aria2_status(&self) -> &'static str {
137        match self {
138            Self::Queued => "waiting",
139            Self::Connecting => "active",
140            Self::Downloading => "active",
141            Self::Seeding => "active",
142            Self::Paused => "paused",
143            Self::Completed => "complete",
144            Self::Error { .. } => "error",
145        }
146    }
147}
148
149/// Progress information for a download
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct DownloadProgress {
152    /// Total size in bytes (may be unknown initially)
153    pub total_size: Option<u64>,
154    /// Bytes downloaded so far
155    pub completed_size: u64,
156    /// Current download speed in bytes/sec
157    pub download_speed: u64,
158    /// Current upload speed in bytes/sec (torrent only)
159    pub upload_speed: u64,
160    /// Number of active connections
161    pub connections: u32,
162    /// Number of seeders (torrent only)
163    pub seeders: u32,
164    /// Number of peers (torrent only)
165    pub peers: u32,
166    /// Estimated time remaining in seconds
167    pub eta_seconds: Option<u64>,
168}
169
170impl DownloadProgress {
171    /// Calculate progress percentage (0.0 - 100.0)
172    pub fn percentage(&self) -> f64 {
173        match self.total_size {
174            Some(total) if total > 0 => (self.completed_size as f64 / total as f64) * 100.0,
175            _ => 0.0,
176        }
177    }
178}
179
180// Helper for hex encoding (used by DownloadId)
181mod hex {
182    pub fn encode(bytes: &[u8]) -> String {
183        bytes.iter().map(|b| format!("{:02x}", b)).collect()
184    }
185
186    pub fn decode(s: &str) -> Result<Vec<u8>, ()> {
187        if s.len() % 2 != 0 {
188            return Err(());
189        }
190        (0..s.len())
191            .step_by(2)
192            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
193            .collect()
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn to_gid_returns_16_char_hex_string() {
203        let id = DownloadId::new();
204        let gid = id.to_gid();
205        assert_eq!(gid.len(), 16);
206        assert!(gid.chars().all(|c| c.is_ascii_hexdigit()));
207    }
208
209    #[test]
210    fn from_gid_round_trip_is_lossy() {
211        let id = DownloadId::new();
212        let gid = id.to_gid();
213        let reconstructed = DownloadId::from_gid(&gid).expect("valid GID");
214        assert_ne!(
215            id, reconstructed,
216            "round-trip should be lossy (upper 8 bytes zeroed)"
217        );
218        assert_eq!(gid, reconstructed.to_gid());
219    }
220
221    #[test]
222    fn matches_gid_works_without_round_trip() {
223        let id = DownloadId::new();
224        let gid = id.to_gid();
225        assert!(id.matches_gid(&gid));
226        let other = DownloadId::new();
227        assert!(!other.matches_gid(&gid));
228    }
229
230    #[test]
231    fn from_gid_rejects_invalid_input() {
232        assert!(DownloadId::from_gid("").is_none());
233        assert!(DownloadId::from_gid("abc").is_none());
234        assert!(DownloadId::from_gid("0123456789abcde").is_none());
235        assert!(DownloadId::from_gid("0123456789abcdef0").is_none());
236        assert!(DownloadId::from_gid("zzzzzzzzzzzzzzzz").is_none());
237    }
238
239    #[test]
240    fn from_gid_accepts_valid_input() {
241        let id = DownloadId::from_gid("0123456789abcdef");
242        assert!(id.is_some());
243        assert_eq!(id.unwrap().to_gid(), "0123456789abcdef");
244    }
245
246    #[test]
247    fn matches_gid_rejects_wrong_length() {
248        let id = DownloadId::new();
249        assert!(!id.matches_gid("abc"));
250        assert!(!id.matches_gid(""));
251    }
252}