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::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
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 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
88pub 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
225pub 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}