Skip to main content

romm_cli/commands/
download.rs

1use anyhow::{anyhow, Result};
2use clap::{Args, Subcommand};
3use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
4use std::path::PathBuf;
5use std::sync::Arc;
6use tokio::sync::Semaphore;
7
8use crate::client::RommClient;
9use crate::core::download::download_directory;
10use crate::core::utils;
11use crate::endpoints::roms::GetRoms;
12use crate::services::RomService;
13
14/// Maximum number of concurrent download connections.
15const DEFAULT_CONCURRENCY: usize = 4;
16
17/// Download a ROM to the local filesystem with a progress bar.
18#[derive(Args, Debug)]
19pub struct DownloadCommand {
20    #[command(subcommand)]
21    pub action: Option<DownloadAction>,
22
23    /// ID of the ROM to download (legacy, use 'download one <id>' or positional)
24    pub rom_id: Option<u64>,
25
26    /// Directory to save the ROM zip(s) to
27    #[arg(short, long, global = true)]
28    pub output: Option<PathBuf>,
29
30    /// Download all ROMs matching the given filters concurrently (legacy, use 'download batch')
31    #[arg(long, global = true)]
32    pub batch: bool,
33
34    /// Filter by platform ID
35    #[arg(long, global = true)]
36    pub platform_id: Option<u64>,
37
38    /// Filter by search term
39    #[arg(long, global = true)]
40    pub search_term: Option<String>,
41
42    /// Maximum concurrent downloads (default: 4)
43    #[arg(long, default_value_t = DEFAULT_CONCURRENCY, global = true)]
44    pub jobs: usize,
45
46    /// Resume interrupted downloads instead of re-downloading
47    #[arg(long, default_value_t = true, global = true)]
48    pub resume: bool,
49}
50
51#[derive(Subcommand, Debug)]
52pub enum DownloadAction {
53    /// Download a single ROM by ID
54    #[command(visible_alias = "one")]
55    Id {
56        /// ID of the ROM
57        id: u64,
58    },
59    /// Download multiple ROMs matching filters
60    #[command(visible_alias = "all")]
61    Batch,
62}
63
64fn make_progress_style() -> ProgressStyle {
65    ProgressStyle::with_template(
66        "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} ({eta}) {msg}",
67    )
68    .unwrap()
69    .progress_chars("#>-")
70}
71
72async fn download_one(
73    client: &RommClient,
74    rom_id: u64,
75    name: &str,
76    save_path: &std::path::Path,
77    pb: ProgressBar,
78) -> Result<()> {
79    pb.set_message(name.to_string());
80
81    client
82        .download_rom(rom_id, save_path, {
83            let pb = pb.clone();
84            move |received, total| {
85                if pb.length() != Some(total) {
86                    pb.set_length(total);
87                }
88                pb.set_position(received);
89            }
90        })
91        .await?;
92
93    pb.finish_with_message(format!("✓ {name}"));
94    Ok(())
95}
96
97pub async fn handle(cmd: DownloadCommand, client: &RommClient) -> Result<()> {
98    let output_dir = cmd.output.unwrap_or_else(download_directory);
99
100    // Ensure output directory exists.
101    tokio::fs::create_dir_all(&output_dir)
102        .await
103        .map_err(|e| anyhow!("create download dir {:?}: {e}", output_dir))?;
104
105    // Determine if we are in batch mode.
106    // In order of priority: subcommand 'batch', then legacy '--batch' flag.
107    let is_batch = matches!(cmd.action, Some(DownloadAction::Batch)) || cmd.batch;
108
109    if is_batch {
110        // ── Batch mode ─────────────────────────────────────────────────
111        if cmd.platform_id.is_none() && cmd.search_term.is_none() {
112            return Err(anyhow!(
113                "Batch download requires at least --platform-id or --search-term to scope the download"
114            ));
115        }
116
117        let ep = GetRoms {
118            search_term: cmd.search_term.clone(),
119            platform_id: cmd.platform_id,
120            collection_id: None,
121            smart_collection_id: None,
122            virtual_collection_id: None,
123            limit: Some(9999),
124            offset: None,
125        };
126
127        let service = RomService::new(client);
128        let results = service.search_roms(&ep).await?;
129
130        if results.items.is_empty() {
131            println!("No ROMs found matching the given filters.");
132            return Ok(());
133        }
134
135        println!(
136            "Found {} ROM(s). Starting download with {} concurrent connections...",
137            results.items.len(),
138            cmd.jobs
139        );
140
141        let mp = MultiProgress::new();
142        let semaphore = Arc::new(Semaphore::new(cmd.jobs));
143        let mut handles = Vec::new();
144
145        for rom in results.items {
146            let permit = semaphore.clone().acquire_owned().await.unwrap();
147            let client = client.clone();
148            let dir = output_dir.clone();
149            let pb = mp.add(ProgressBar::new(0));
150            pb.set_style(make_progress_style());
151
152            let name = rom.name.clone();
153            let rom_id = rom.id;
154            let base = utils::sanitize_filename(&rom.fs_name);
155            let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(&base);
156            let save_path = dir.join(format!("{stem}.zip"));
157
158            handles.push(tokio::spawn(async move {
159                let result = download_one(&client, rom_id, &name, &save_path, pb).await;
160                drop(permit);
161                if let Err(e) = &result {
162                    eprintln!("error downloading {name} (id={rom_id}): {e}");
163                }
164                result
165            }));
166        }
167
168        let mut successes = 0u32;
169        let mut failures = 0u32;
170        for handle in handles {
171            match handle.await {
172                Ok(Ok(())) => successes += 1,
173                _ => failures += 1,
174            }
175        }
176
177        println!("\nBatch complete: {successes} succeeded, {failures} failed.");
178    } else {
179        // ── Single ROM mode ────────────────────────────────────────────
180        let rom_id = if let Some(DownloadAction::Id { id }) = cmd.action {
181            id
182        } else {
183            cmd.rom_id
184                .ok_or_else(|| anyhow!("ROM ID is required (e.g. 'download 123' or 'download batch --search-term ...')"))?
185        };
186
187        let save_path = output_dir.join(format!("rom_{rom_id}.zip"));
188
189        let mp = MultiProgress::new();
190        let pb = mp.add(ProgressBar::new(0));
191        pb.set_style(make_progress_style());
192
193        download_one(client, rom_id, &format!("ROM {rom_id}"), &save_path, pb).await?;
194
195        println!("Saved to {:?}", save_path);
196    }
197
198    Ok(())
199}