Skip to main content

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