romm_cli/core/
download.rs1use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::sync::{Arc, Mutex};
9
10use crate::client::RommClient;
11use crate::core::utils;
12use crate::types::Rom;
13
14pub fn download_directory() -> PathBuf {
16 std::env::var("ROMM_DOWNLOAD_DIR")
17 .map(PathBuf::from)
18 .unwrap_or_else(|_| PathBuf::from("./downloads"))
19}
20
21pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
23 let mut n = 1u32;
24 loop {
25 let name = if n == 1 {
26 format!("{}.zip", stem)
27 } else {
28 format!("{}__{}.zip", stem, n)
29 };
30 let p = dir.join(name);
31 if !p.exists() {
32 return p;
33 }
34 n = n.saturating_add(1);
35 }
36}
37
38#[derive(Debug, Clone)]
44pub enum DownloadStatus {
45 Downloading,
46 Done,
47 Error(String),
48}
49
50#[derive(Debug, Clone)]
52pub struct DownloadJob {
53 pub id: usize,
54 pub rom_id: u64,
55 pub name: String,
56 pub platform: String,
57 pub progress: f64,
59 pub status: DownloadStatus,
60}
61
62static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);
63
64impl DownloadJob {
65 pub fn new(rom_id: u64, name: String, platform: String) -> Self {
67 Self {
68 id: NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed),
69 rom_id,
70 name,
71 platform,
72 progress: 0.0,
73 status: DownloadStatus::Downloading,
74 }
75 }
76
77 pub fn percent(&self) -> u16 {
79 (self.progress * 100.0).round().min(100.0) as u16
80 }
81}
82
83#[derive(Clone)]
91pub struct DownloadManager {
92 jobs: Arc<Mutex<Vec<DownloadJob>>>,
93}
94
95impl Default for DownloadManager {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl DownloadManager {
102 pub fn new() -> Self {
103 Self {
104 jobs: Arc::new(Mutex::new(Vec::new())),
105 }
106 }
107
108 pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
110 self.jobs.clone()
111 }
112
113 pub fn start_download(&self, rom: &Rom, client: RommClient) {
118 let platform = rom
119 .platform_display_name
120 .as_deref()
121 .or(rom.platform_custom_name.as_deref())
122 .unwrap_or("—")
123 .to_string();
124
125 let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
126 let job_id = job.id;
127 let rom_id = rom.id;
128 let fs_name = rom.fs_name.clone();
129 match self.jobs.lock() {
130 Ok(mut jobs) => jobs.push(job),
131 Err(err) => {
132 eprintln!("warning: download job list lock poisoned: {}", err);
133 return;
134 }
135 }
136
137 let jobs = self.jobs.clone();
138 tokio::spawn(async move {
139 let save_dir = download_directory();
140 if let Err(err) = tokio::fs::create_dir_all(&save_dir).await {
141 eprintln!(
142 "warning: failed to create download directory {:?}: {}",
143 save_dir, err
144 );
145 }
146 let base = utils::sanitize_filename(&fs_name);
147 let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(&base);
148 let save_path = unique_zip_path(&save_dir, stem);
149
150 let on_progress = |received: u64, total: u64| {
151 let p = if total > 0 {
152 received as f64 / total as f64
153 } else {
154 0.0
155 };
156
157 if let Ok(mut list) = jobs.lock() {
158 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
159 j.progress = p;
160 }
161 }
162 };
163
164 let download_result = client.download_rom(rom_id, &save_path, on_progress).await;
165 if download_result.is_err() {
166 let _ = tokio::fs::remove_file(&save_path).await;
167 }
168 match download_result {
169 Ok(()) => {
170 if let Ok(mut list) = jobs.lock() {
171 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
172 j.status = DownloadStatus::Done;
173 j.progress = 1.0;
174 }
175 }
176 }
177 Err(e) => {
178 if let Ok(mut list) = jobs.lock() {
179 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
180 j.status = DownloadStatus::Error(e.to_string());
181 }
182 }
183 }
184 }
185 });
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use std::io::Write;
193 use std::time::{SystemTime, UNIX_EPOCH};
194
195 #[test]
196 fn unique_zip_path_skips_existing_files() {
197 let ts = SystemTime::now()
198 .duration_since(UNIX_EPOCH)
199 .unwrap()
200 .as_nanos();
201 let dir = std::env::temp_dir().join(format!("romm-dl-test-{ts}"));
202 std::fs::create_dir_all(&dir).unwrap();
203 let p1 = dir.join("game.zip");
204 std::fs::File::create(&p1).unwrap().write_all(b"x").unwrap();
205 let p2 = unique_zip_path(&dir, "game");
206 assert_eq!(p2.file_name().unwrap(), "game__2.zip");
207 let _ = std::fs::remove_file(&p1);
208 let _ = std::fs::remove_dir(&dir);
209 }
210}