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