1use 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
62pub 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
85pub 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
213pub 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}