gosh_dl/protocol/
types.rs1use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct DownloadId(Uuid);
11
12impl DownloadId {
13 pub fn new() -> Self {
15 Self(Uuid::new_v4())
16 }
17
18 pub fn from_uuid(uuid: Uuid) -> Self {
20 Self(uuid)
21 }
22
23 pub fn as_uuid(&self) -> &Uuid {
25 &self.0
26 }
27
28 pub fn to_gid(&self) -> String {
38 hex::encode(&self.0.as_bytes()[0..8])
39 }
40
41 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum DownloadKind {
92 Http,
94 Torrent,
96 Magnet,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(tag = "state", rename_all = "lowercase")]
103pub enum DownloadState {
104 Queued,
106 Connecting,
108 Downloading,
110 Seeding,
112 Paused,
114 Completed,
116 Error {
118 kind: String,
119 message: String,
120 retryable: bool,
121 },
122}
123
124impl DownloadState {
125 pub fn is_active(&self) -> bool {
127 matches!(self, Self::Downloading | Self::Seeding | Self::Connecting)
128 }
129
130 pub fn is_finished(&self) -> bool {
132 matches!(self, Self::Completed | Self::Error { .. })
133 }
134
135 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct DownloadProgress {
152 pub total_size: Option<u64>,
154 pub completed_size: u64,
156 pub download_speed: u64,
158 pub upload_speed: u64,
160 pub connections: u32,
162 pub seeders: u32,
164 pub peers: u32,
166 pub eta_seconds: Option<u64>,
168}
169
170impl DownloadProgress {
171 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
180mod 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}