Skip to main content

romm_cli/core/
extras.rs

1//! Build download targets for ROM extras (related archives, cover, manual).
2//!
3//! Shared by the CLI `download extras` subcommand and the TUI extras picker.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::Result;
8
9use crate::client::RommClient;
10use crate::config::RomsLayoutConfig;
11use crate::core::download::resolve_console_roms_dir;
12use crate::core::utils;
13use crate::endpoints::roms::GetRom;
14use crate::endpoints::roms::GetRoms;
15use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
16
17use crate::types::{Rom, RomFile, RomFileCategory};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DownloadAssetKind {
21    RomArchive,
22    RomFile,
23    Cover,
24    Manual,
25}
26
27impl DownloadAssetKind {
28    pub fn folder_name(self) -> &'static str {
29        match self {
30            DownloadAssetKind::RomArchive => "roms",
31            DownloadAssetKind::RomFile => "roms",
32            DownloadAssetKind::Cover => "covers",
33            DownloadAssetKind::Manual => "manuals",
34        }
35    }
36
37    pub fn label(self) -> &'static str {
38        match self {
39            DownloadAssetKind::RomArchive => "ROM archive",
40            DownloadAssetKind::RomFile => "ROM file",
41            DownloadAssetKind::Cover => "cover",
42            DownloadAssetKind::Manual => "manual",
43        }
44    }
45}
46
47#[derive(Debug, Clone)]
48pub struct DownloadTarget {
49    pub kind: DownloadAssetKind,
50    pub title: String,
51    pub source_url: String,
52    pub source_query: Vec<(String, String)>,
53    pub destination: PathBuf,
54    pub expected_size_bytes: Option<u64>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum InternalRomFileGroup {
59    BaseGame,
60    Update,
61    Dlc,
62}
63
64/// Full extras set for one ROM (API fetch + discovery).
65pub async fn build_extras_targets(
66    client: &RommClient,
67    rom_id: u64,
68    layout: &RomsLayoutConfig,
69    base_dir: &Path,
70) -> Result<Vec<DownloadTarget>> {
71    let rom = client.call(&GetRom { id: rom_id }).await?;
72    let extras_root = extras_root_dir(layout, base_dir, &rom)?;
73
74    let mut targets = Vec::new();
75    targets.extend(build_internal_extra_targets(&rom, layout, base_dir)?);
76    targets.extend(build_related_rom_targets(client, &rom, &extras_root).await?);
77    if let Some(cover) = build_cover_target(&rom, &extras_root) {
78        targets.push(cover);
79    }
80    if let Some(manual) = build_manual_target(&rom, &extras_root) {
81        targets.push(manual);
82    }
83
84    Ok(targets)
85}
86
87/// Updates/DLC set for the normal single-ROM download follow-up prompt.
88pub async fn build_update_dlc_targets_for_rom(
89    client: &RommClient,
90    rom: &Rom,
91    layout: &RomsLayoutConfig,
92    base_dir: &Path,
93) -> Result<Vec<DownloadTarget>> {
94    let extras_root = extras_root_dir(layout, base_dir, rom)?;
95    let related_rows = related_rom_rows(client, rom).await?;
96    build_update_dlc_targets_from_related_rows(rom, &related_rows, layout, base_dir, &extras_root)
97}
98
99pub fn has_update_or_dlc_extras(rom: &Rom, related_rows: &[Rom]) -> bool {
100    !internal_file_subset(rom, InternalRomFileGroup::Update).is_empty()
101        || !internal_file_subset(rom, InternalRomFileGroup::Dlc).is_empty()
102        || !related_rows.is_empty()
103}
104
105pub fn build_base_rom_file_targets(
106    rom: &Rom,
107    layout: &RomsLayoutConfig,
108    base_dir: &Path,
109) -> Result<Vec<DownloadTarget>> {
110    let base_files = internal_file_subset(rom, InternalRomFileGroup::BaseGame);
111    if base_files.is_empty() {
112        return Ok(Vec::new());
113    }
114    let platform_dir = resolve_console_roms_dir(layout, base_dir, rom)?;
115    Ok(base_files
116        .into_iter()
117        .map(|file| {
118            internal_rom_file_target(rom, file, &platform_dir, InternalRomFileGroup::BaseGame)
119        })
120        .collect())
121}
122
123pub fn build_update_dlc_file_targets_for_rom(
124    rom: &Rom,
125    layout: &RomsLayoutConfig,
126    base_dir: &Path,
127) -> Result<Vec<DownloadTarget>> {
128    build_internal_extra_targets(rom, layout, base_dir)
129}
130
131pub fn collect_update_dlc_files(rom: &Rom) -> Vec<RomFile> {
132    let mut out = internal_file_subset(rom, InternalRomFileGroup::Update);
133    out.extend(internal_file_subset(rom, InternalRomFileGroup::Dlc));
134    out
135}
136
137async fn build_related_rom_targets(
138    client: &RommClient,
139    rom: &Rom,
140    extras_root: &Path,
141) -> Result<Vec<DownloadTarget>> {
142    let related_rows = related_rom_rows(client, rom).await?;
143    Ok(related_rows
144        .iter()
145        .map(|candidate| related_rom_download_target(rom, candidate, extras_root))
146        .collect())
147}
148
149async fn related_rom_rows(client: &RommClient, rom: &Rom) -> Result<Vec<Rom>> {
150    let ep = GetRoms {
151        search_term: Some(rom.name.clone()),
152        platform_id: Some(rom.platform_id),
153        limit: Some(9999),
154        ..Default::default()
155    };
156    let results = client.call(&ep).await?;
157    let groups = utils::group_roms_by_name(&results.items);
158    let Some(group) = groups.iter().find(|g| g.name == rom.name) else {
159        return Ok(Vec::new());
160    };
161
162    let mut rows = Vec::new();
163    let mut seen = std::collections::HashSet::new();
164    let mut push_rom = |candidate: &Rom| {
165        if candidate.id == rom.id || !seen.insert(candidate.id) {
166            return;
167        }
168        rows.push(candidate.clone());
169    };
170
171    push_rom(&group.primary);
172    for other in &group.others {
173        push_rom(other);
174    }
175    Ok(rows)
176}
177
178pub fn build_update_dlc_targets_from_related_rows(
179    rom: &Rom,
180    related_rows: &[Rom],
181    layout: &RomsLayoutConfig,
182    base_dir: &Path,
183    extras_root: &Path,
184) -> Result<Vec<DownloadTarget>> {
185    let mut targets = build_internal_extra_targets(rom, layout, base_dir)?;
186    targets.extend(
187        related_rows
188            .iter()
189            .map(|candidate| related_rom_download_target(rom, candidate, extras_root)),
190    );
191    Ok(targets)
192}
193
194fn build_internal_extra_targets(
195    rom: &Rom,
196    layout: &RomsLayoutConfig,
197    base_dir: &Path,
198) -> Result<Vec<DownloadTarget>> {
199    let updates = internal_file_subset(rom, InternalRomFileGroup::Update);
200    let dlc = internal_file_subset(rom, InternalRomFileGroup::Dlc);
201    let mut out = Vec::with_capacity(updates.len() + dlc.len());
202    let platform_dir = resolve_console_roms_dir(layout, base_dir, rom)?;
203    let game_dir = sanitized_extra_game_name(&rom.name, rom.id);
204    for f in updates {
205        out.push(internal_rom_file_target(
206            rom,
207            f,
208            &platform_dir.join("updates").join(&game_dir),
209            InternalRomFileGroup::Update,
210        ));
211    }
212    for f in dlc {
213        out.push(internal_rom_file_target(
214            rom,
215            f,
216            &platform_dir.join("dlc").join(&game_dir),
217            InternalRomFileGroup::Dlc,
218        ));
219    }
220    Ok(out)
221}
222
223/// One related ROM archive under `extras_root` (same layout as CLI extras).
224pub fn related_rom_download_target(
225    _parent: &Rom,
226    candidate: &Rom,
227    extras_root: &Path,
228) -> DownloadTarget {
229    let name = sanitize_extra_file_name(&candidate.fs_name);
230    DownloadTarget {
231        kind: DownloadAssetKind::RomArchive,
232        title: candidate.fs_name.clone(),
233        source_url: "/api/roms/download".to_string(),
234        source_query: vec![
235            ("rom_ids".into(), candidate.id.to_string()),
236            ("filename".into(), name.clone()),
237        ],
238        destination: extras_root
239            .join(DownloadAssetKind::RomArchive.folder_name())
240            .join(name),
241        expected_size_bytes: None,
242    }
243}
244
245fn internal_rom_file_target(
246    _parent: &Rom,
247    file: RomFile,
248    destination_dir: &Path,
249    group: InternalRomFileGroup,
250) -> DownloadTarget {
251    let encoded_name = utf8_percent_encode(&file.file_name, NON_ALPHANUMERIC).to_string();
252    let source_url = format!("/api/roms/{}/files/content/{}", file.id, encoded_name);
253    let title = match group {
254        InternalRomFileGroup::BaseGame => file.file_name.clone(),
255        InternalRomFileGroup::Update => format!("Update: {}", file.file_name),
256        InternalRomFileGroup::Dlc => format!("DLC: {}", file.file_name),
257    };
258    let output_name = sanitize_extra_file_name(&file.file_name);
259    DownloadTarget {
260        kind: DownloadAssetKind::RomFile,
261        title,
262        source_url,
263        source_query: Vec::new(),
264        destination: destination_dir.join(output_name),
265        expected_size_bytes: Some(file.file_size_bytes),
266    }
267}
268
269pub fn build_cover_target(rom: &Rom, extras_root: &Path) -> Option<DownloadTarget> {
270    let url = rom
271        .url_cover
272        .as_deref()
273        .map(str::trim)
274        .filter(|u| !u.is_empty())?;
275    let filename = filename_from_url(url, "cover");
276    Some(DownloadTarget {
277        kind: DownloadAssetKind::Cover,
278        title: rom.name.clone(),
279        source_url: url.to_string(),
280        source_query: Vec::new(),
281        destination: extras_root
282            .join(DownloadAssetKind::Cover.folder_name())
283            .join(filename),
284        expected_size_bytes: None,
285    })
286}
287
288pub fn build_manual_target(rom: &Rom, extras_root: &Path) -> Option<DownloadTarget> {
289    let url = rom
290        .url_manual
291        .as_deref()
292        .map(str::trim)
293        .filter(|u| !u.is_empty())?;
294    let filename = filename_from_url(url, "manual");
295    Some(DownloadTarget {
296        kind: DownloadAssetKind::Manual,
297        title: rom.name.clone(),
298        source_url: url.to_string(),
299        source_query: Vec::new(),
300        destination: extras_root
301            .join(DownloadAssetKind::Manual.folder_name())
302            .join(filename),
303        expected_size_bytes: None,
304    })
305}
306
307pub fn extras_root_dir(layout: &RomsLayoutConfig, base_dir: &Path, rom: &Rom) -> Result<PathBuf> {
308    let platform_dir = resolve_console_roms_dir(layout, base_dir, rom)?;
309    let game_slug = sanitized_extra_game_name(&rom.name, rom.id);
310    Ok(platform_dir.join(game_slug).join("extras"))
311}
312
313fn internal_file_subset(rom: &Rom, group: InternalRomFileGroup) -> Vec<RomFile> {
314    rom.files
315        .iter()
316        .filter(|f| match group {
317            InternalRomFileGroup::BaseGame => is_base_game_file(f),
318            InternalRomFileGroup::Update => is_update_file(f),
319            InternalRomFileGroup::Dlc => is_dlc_file(f),
320        })
321        .cloned()
322        .collect()
323}
324
325fn is_update_file(file: &RomFile) -> bool {
326    if matches!(file.category, Some(RomFileCategory::Update)) {
327        return true;
328    }
329    filename_has_token(&file.file_name, &["update", "upd"])
330}
331
332fn is_dlc_file(file: &RomFile) -> bool {
333    if matches!(file.category, Some(RomFileCategory::Dlc)) {
334        return true;
335    }
336    filename_has_token(&file.file_name, &["dlc", "expansion"])
337}
338
339fn is_base_game_file(file: &RomFile) -> bool {
340    if matches!(file.category, Some(RomFileCategory::Game)) {
341        return true;
342    }
343    if file.category.is_some() {
344        return false;
345    }
346    !is_update_file(file) && !is_dlc_file(file)
347}
348
349fn filename_has_token(name: &str, tokens: &[&str]) -> bool {
350    let normalized = name.to_ascii_lowercase();
351    let normalized = normalized.replace(['[', ']', '(', ')', '{', '}', '-', '_', '.'], " ");
352    normalized
353        .split_whitespace()
354        .any(|part| tokens.contains(&part))
355}
356
357fn sanitized_extra_game_name(name: &str, rom_id: u64) -> String {
358    let sanitized = utils::sanitize_filename(name);
359    if sanitized.trim().is_empty() {
360        format!("rom-{rom_id}")
361    } else {
362        sanitized
363    }
364}
365
366fn sanitize_extra_file_name(name: &str) -> String {
367    let sanitized = utils::sanitize_filename(name);
368    if sanitized.trim().is_empty() {
369        "download.bin".to_string()
370    } else {
371        sanitized
372    }
373}
374
375fn filename_from_url(url: &str, fallback: &str) -> String {
376    let fallback = sanitize_extra_file_name(fallback);
377    reqwest::Url::parse(url)
378        .ok()
379        .and_then(|parsed| {
380            parsed
381                .path_segments()
382                .and_then(|mut segments| segments.next_back().map(str::to_string))
383        })
384        .map(|name| sanitize_extra_file_name(&name))
385        .filter(|name| !name.trim().is_empty())
386        .unwrap_or(fallback)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::config::RomsLayoutConfig;
393    use crate::types::Rom;
394
395    fn default_layout() -> RomsLayoutConfig {
396        RomsLayoutConfig::default()
397    }
398
399    #[test]
400    fn extras_root_dir_is_sanitized() {
401        let rom = rom_fixture(7, "Mario Kart", "Mario Kart [USA].zip");
402        let dir = extras_root_dir(&default_layout(), Path::new("/tmp/out"), &rom).unwrap();
403        assert_eq!(
404            dir,
405            PathBuf::from("/tmp/out")
406                .join("Nintendo Switch")
407                .join("Mario Kart")
408                .join("extras")
409        );
410    }
411
412    #[test]
413    fn filename_from_url_uses_remote_leaf_or_fallback() {
414        assert_eq!(
415            filename_from_url("https://example.com/files/guide.pdf?download=1", "manual"),
416            "guide.pdf"
417        );
418        assert_eq!(filename_from_url("not-a-url", "manual"), "manual");
419    }
420
421    #[test]
422    fn build_cover_and_manual_when_urls_present() {
423        let mut rom = rom_fixture(1, "Game", "game.zip");
424        rom.url_cover = Some("https://cdn.example.com/cover.png".into());
425        rom.url_manual = Some("https://cdn.example.com/doc.pdf".into());
426        let root = PathBuf::from("/out/extras");
427        let cover = build_cover_target(&rom, &root).expect("cover");
428        assert_eq!(cover.kind, DownloadAssetKind::Cover);
429        assert!(cover.destination.ends_with("covers/cover.png"));
430        let manual = build_manual_target(&rom, &root).expect("manual");
431        assert_eq!(manual.kind, DownloadAssetKind::Manual);
432        assert!(manual.destination.ends_with("manuals/doc.pdf"));
433    }
434
435    #[test]
436    fn build_cover_skips_when_missing_url() {
437        let rom = rom_fixture(1, "Game", "game.zip");
438        let root = PathBuf::from("/out/extras");
439        assert!(build_cover_target(&rom, &root).is_none());
440        assert!(build_manual_target(&rom, &root).is_none());
441    }
442
443    fn rom_fixture(id: u64, name: &str, fs_name: &str) -> Rom {
444        Rom {
445            id,
446            platform_id: 1,
447            platform_slug: Some("switch".to_string()),
448            platform_fs_slug: Some("Nintendo Switch".to_string()),
449            platform_custom_name: None,
450            platform_display_name: None,
451            fs_name: fs_name.to_string(),
452            fs_name_no_tags: name.to_string(),
453            fs_name_no_ext: name.to_string(),
454            fs_extension: "zip".to_string(),
455            fs_path: format!("/{id}.zip"),
456            fs_size_bytes: 1,
457            name: name.to_string(),
458            slug: None,
459            summary: None,
460            path_cover_small: None,
461            path_cover_large: None,
462            url_cover: None,
463            has_manual: false,
464            path_manual: None,
465            url_manual: None,
466            is_unidentified: false,
467            is_identified: true,
468            files: Vec::new(),
469        }
470    }
471
472    #[test]
473    fn base_file_targets_build_from_internal_game_files() {
474        let mut rom = rom_fixture(1, "Game", "pack.zip");
475        rom.files = vec![
476            RomFile {
477                id: 10,
478                rom_id: 1,
479                file_name: "base.nsp".into(),
480                file_path: "/base.nsp".into(),
481                file_size_bytes: 10,
482                category: Some(RomFileCategory::Game),
483            },
484            RomFile {
485                id: 11,
486                rom_id: 1,
487                file_name: "upd.nsp".into(),
488                file_path: "/upd.nsp".into(),
489                file_size_bytes: 10,
490                category: Some(RomFileCategory::Update),
491            },
492        ];
493        let targets =
494            build_base_rom_file_targets(&rom, &default_layout(), Path::new("/tmp/out")).unwrap();
495        assert_eq!(targets.len(), 1);
496        assert_eq!(targets[0].kind, DownloadAssetKind::RomFile);
497        assert_eq!(
498            targets[0].source_url,
499            "/api/roms/10/files/content/base%2Ensp"
500        );
501        assert!(targets[0].destination.ends_with("Nintendo Switch/base.nsp"));
502    }
503
504    #[test]
505    fn detects_update_dlc_by_filename_when_category_missing() {
506        let mut rom = rom_fixture(1, "Game", "pack.zip");
507        rom.files = vec![
508            RomFile {
509                id: 10,
510                rom_id: 1,
511                file_name: "Game [v1.4.0] [Update].nsp".into(),
512                file_path: "/upd.nsp".into(),
513                file_size_bytes: 10,
514                category: None,
515            },
516            RomFile {
517                id: 11,
518                rom_id: 1,
519                file_name: "Game [DLC].nsp".into(),
520                file_path: "/dlc.nsp".into(),
521                file_size_bytes: 10,
522                category: None,
523            },
524            RomFile {
525                id: 12,
526                rom_id: 1,
527                file_name: "Game Base.nsp".into(),
528                file_path: "/base.nsp".into(),
529                file_size_bytes: 10,
530                category: None,
531            },
532        ];
533        assert!(has_update_or_dlc_extras(&rom, &[]));
534        let base =
535            build_base_rom_file_targets(&rom, &default_layout(), Path::new("/tmp/out")).unwrap();
536        assert_eq!(base.len(), 1);
537        assert!(base[0]
538            .destination
539            .ends_with("Nintendo Switch/Game Base.nsp"));
540        let extras =
541            build_update_dlc_file_targets_for_rom(&rom, &default_layout(), Path::new("/tmp/out"))
542                .unwrap();
543        assert_eq!(extras.len(), 2);
544        assert_eq!(
545            extras[0].source_url,
546            "/api/roms/10/files/content/Game%20%5Bv1%2E4%2E0%5D%20%5BUpdate%5D%2Ensp"
547        );
548        assert!(extras[0]
549            .destination
550            .ends_with("Nintendo Switch/updates/Game/Game _v1.4.0_ _Update_.nsp"));
551        assert_eq!(
552            extras[1].source_url,
553            "/api/roms/11/files/content/Game%20%5BDLC%5D%2Ensp"
554        );
555        assert!(extras[1]
556            .destination
557            .ends_with("Nintendo Switch/dlc/Game/Game _DLC_.nsp"));
558    }
559
560    #[test]
561    fn update_dlc_targets_include_related_roms_but_not_cover_or_manual() {
562        let mut rom = rom_fixture(1, "Game", "pack.zip");
563        rom.url_cover = Some("https://example.com/cover.png".into());
564        rom.url_manual = Some("https://example.com/manual.pdf".into());
565        rom.files = vec![RomFile {
566            id: 10,
567            rom_id: 1,
568            file_name: "Game [Update].nsp".into(),
569            file_path: "/upd.nsp".into(),
570            file_size_bytes: 10,
571            category: Some(RomFileCategory::Update),
572        }];
573        let related = Rom {
574            id: 2,
575            fs_name: "Game DLC.zip".into(),
576            ..rom_fixture(2, "Game", "Game DLC.zip")
577        };
578        let extras_root = extras_root_dir(&default_layout(), Path::new("/tmp/out"), &rom).unwrap();
579
580        let targets = build_update_dlc_targets_from_related_rows(
581            &rom,
582            &[related],
583            &default_layout(),
584            Path::new("/tmp/out"),
585            &extras_root,
586        )
587        .unwrap();
588
589        assert_eq!(targets.len(), 2);
590        assert!(targets.iter().any(|t| t.kind == DownloadAssetKind::RomFile));
591        assert!(targets
592            .iter()
593            .any(|t| t.kind == DownloadAssetKind::RomArchive));
594        assert!(!targets.iter().any(|t| t.kind == DownloadAssetKind::Cover));
595        assert!(!targets.iter().any(|t| t.kind == DownloadAssetKind::Manual));
596    }
597}