1use 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
63pub 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
86pub 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
222pub 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}