1use 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
64pub 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
87pub 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
223pub 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}