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            limit: Some(9999),
122            offset: None,
123        };
124
125        let service = RomService::new(client);
126        let results = service.search_roms(&ep).await?;
127
128        if results.items.is_empty() {
129            println!("No ROMs found matching the given filters.");
130            return Ok(());
131        }
132
133        println!(
134            "Found {} ROM(s). Starting download with {} concurrent connections...",
135            results.items.len(),
136            cmd.jobs
137        );
138
139        let mp = MultiProgress::new();
140        let semaphore = Arc::new(Semaphore::new(cmd.jobs));
141        let mut handles = Vec::new();
142
143        for rom in results.items {
144            let permit = semaphore.clone().acquire_owned().await.unwrap();
145            let client = client.clone();
146            let dir = output_dir.clone();
147            let pb = mp.add(ProgressBar::new(0));
148            pb.set_style(make_progress_style());
149
150            let name = rom.name.clone();
151            let rom_id = rom.id;
152            let base = utils::sanitize_filename(&rom.fs_name);
153            let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(&base);
154            let save_path = dir.join(format!("{stem}.zip"));
155
156            handles.push(tokio::spawn(async move {
157                let result = download_one(&client, rom_id, &name, &save_path, pb).await;
158                drop(permit);
159                if let Err(e) = &result {
160                    eprintln!("error downloading {name} (id={rom_id}): {e}");
161                }
162                result
163            }));
164        }
165
166        let mut successes = 0u32;
167        let mut failures = 0u32;
168        for handle in handles {
169            match handle.await {
170                Ok(Ok(())) => successes += 1,
171                _ => failures += 1,
172            }
173        }
174
175        println!("\nBatch complete: {successes} succeeded, {failures} failed.");
176    } else {
177        // ── Single ROM mode ────────────────────────────────────────────
178        let rom_id = if let Some(DownloadAction::Id { id }) = cmd.action {
179            id
180        } else {
181            cmd.rom_id
182                .ok_or_else(|| anyhow!("ROM ID is required (e.g. 'download 123' or 'download batch --search-term ...')"))?
183        };
184
185        let save_path = output_dir.join(format!("rom_{rom_id}.zip"));
186
187        let mp = MultiProgress::new();
188        let pb = mp.add(ProgressBar::new(0));
189        pb.set_style(make_progress_style());
190
191        download_one(client, rom_id, &format!("ROM {rom_id}"), &save_path, pb).await?;
192
193        println!("Saved to {:?}", save_path);
194    }
195
196    Ok(())
197}