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