1use jgenesis_native_config::{RecentOpen, RomSearchDirectory}; 2use jgenesis_native_driver::extensions; 3use jgenesis_native_driver::extensions::{Console, ConsoleWithSize}; 4use regex::Regex; 5use std::collections::HashSet; 6use std::ffi::OsStr; 7use std::path::{Path, PathBuf}; 8use std::str::FromStr; 9use std::sync::atomic::{AtomicU32, Ordering}; 10use std::sync::mpsc::Sender; 11use std::sync::{Arc, LazyLock, Mutex, mpsc}; 12use std::{fs, io, thread}; 13 14#[derive(Debug, Clone)] 15pub struct RomMetadata { 16 pub full_path: PathBuf, 17 pub file_name_no_ext: String, 18 pub console: Console, 19 pub file_size: u64, 20} 21 22pub fn build(rom_search_dirs: &[RomSearchDirectory]) -> Vec<RomMetadata> { 23 let mut searched = HashSet::new(); 24 let mut metadata = Vec::new(); 25 for rom_search_dir in rom_search_dirs { 26 let search_path = jgenesis_common::fix_appimage_relative_path(&rom_search_dir.path); 27 search_dir(&search_path, rom_search_dir.recursive, &mut searched, &mut metadata); 28 } 29 30 // Remove any files that are referenced in .cue files 31 let cd_bin_file_names = metadata 32 .iter() 33 .filter(|metadata| extensions::from_path(&metadata.full_path).as_deref() == Some("cue")) 34 .filter_map(|metadata| { 35 let path = Path::new(&metadata.full_path); 36 37 let cue_directory = path.parent()?; 38 let cue_contents = fs::read_to_string(path).ok()?; 39 40 let file_paths = parse_bin_file_names(&cue_contents) 41 .map(|file_name| cue_directory.join(file_name)) 42 .collect::<Vec<_>>(); 43 Some(file_paths) 44 }) 45 .flatten() 46 .collect::<HashSet<_>>(); 47 48 metadata.retain(|metadata| !cd_bin_file_names.contains(&metadata.full_path)); 49 50 metadata.sort_by(|a, b| a.file_name_no_ext.cmp(&b.file_name_no_ext)); 51 metadata 52} 53 54fn search_dir( 55 path: &Path, 56 recursive: bool, 57 searched: &mut HashSet<PathBuf>, 58 output: &mut Vec<RomMetadata>, 59) { 60 let Ok(canonical_path) = path.canonicalize() else { return }; 61 62 if !searched.insert(canonical_path) { 63 // Prevent potential infinite loops due to symlinks, or avoid double-searching a path if 64 // it's contained within multiple search paths 65 return; 66 } 67 68 let Ok(read_dir) = path.read_dir() else { return }; 69 70 for dir_entry in read_dir { 71 let Ok(dir_entry) = dir_entry else { continue }; 72 let Ok(metadata) = dir_entry.metadata() else { continue }; 73 let entry_path = dir_entry.path(); 74 75 if metadata.is_file() { 76 let file_name = dir_entry.file_name(); 77 if let Some(rom_metadata) = process_file(&file_name.to_string_lossy(), &entry_path) { 78 output.push(rom_metadata); 79 } 80 } else if recursive && metadata.is_dir() { 81 search_dir(&entry_path, recursive, searched, output); 82 } 83 } 84} 85 86fn process_file(file_name: &str, path: &Path) -> Option<RomMetadata> { 87 let ConsoleWithSize { console, file_size: raw_file_size } = Console::from_file(path)?; 88 89 let file_name_no_ext = Path::new(file_name).with_extension("").to_string_lossy().to_string(); 90 let extension = extensions::from_path(path)?; 91 92 let file_size = match extension.as_str() { 93 "cue" => sega_cd_file_size(path).ok()?, 94 _ => raw_file_size, 95 }; 96 97 Some(RomMetadata { full_path: path.into(), file_name_no_ext, console, file_size }) 98} 99 100fn sega_cd_file_size(cue_path: &Path) -> io::Result<u64> { 101 let cue_contents = fs::read_to_string(cue_path)?; 102 let cue_directory = 103 Path::new(cue_path).parent().expect("Valid file should always have a parent dir"); 104 105 let unique_file_names = parse_bin_file_names(&cue_contents).collect::<HashSet<_>>(); 106 107 unique_file_names 108 .iter() 109 .map(|file_name| { 110 let full_path = cue_directory.join(file_name); 111 fs::metadata(full_path).map(|metadata| metadata.len()) 112 }) 113 .sum() 114} 115 116fn parse_bin_file_names(cue_contents: &str) -> impl Iterator<Item = &str> { 117 static LINE_RE: LazyLock<Regex> = 118 LazyLock::new(|| Regex::new(r#"FILE "(.*)" BINARY"#).unwrap()); 119 120 cue_contents 121 .lines() 122 .filter_map(|line| LINE_RE.captures(line).map(|captures| captures.get(1).unwrap().as_str())) 123} 124 125pub fn read_metadata(path: &Path) -> Option<RomMetadata> { 126 let file_name = path.file_name().and_then(OsStr::to_str)?; 127 process_file(file_name, path) 128} 129 130pub fn from_recent_opens(recent_opens: &[RecentOpen]) -> Vec<Option<RomMetadata>> { 131 recent_opens 132 .iter() 133 .map(|RecentOpen { console, path: path_str, .. }| { 134 let console = Console::from_str(console).ok()?; 135 let path = Path::new(path_str); 136 let file_name_no_ext = 137 path.with_extension("").file_name()?.to_string_lossy().to_string(); 138 let metadata = fs::metadata(path).ok()?; 139 140 let file_size = match extensions::from_path(path).as_deref() { 141 Some("cue") => sega_cd_file_size(path_str).ok()?, 142 _ => metadata.len(), 143 }; 144 145 Some(RomMetadata { full_path: path_str.into(), file_name_no_ext, console, file_size }) 146 }) 147 .collect() 148} 149 150pub fn find_all_disc_paths(path: &Path) -> Vec<(String, PathBuf)> { 151 static DISC_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r" \(Disc (\d)\)").unwrap()); 152 153 let Some(path_name_str) = path.file_name().and_then(OsStr::to_str) else { return Vec::new() }; 154 let name_without_disc = DISC_RE.replace(path_name_str, ""); 155 if name_without_disc.as_ref() == path_name_str { 156 return Vec::new(); 157 } 158 159 let Some(parent) = path.parent() else { return Vec::new() }; 160 let Ok(read_dir) = parent.read_dir() else { return Vec::new() }; 161 162 let mut discs = Vec::new(); 163 164 for entry in read_dir { 165 let Ok(entry) = entry else { continue }; 166 let file_name = entry.file_name(); 167 let Some(file_name) = file_name.to_str() else { continue }; 168 169 if DISC_RE.replace(file_name, "") != name_without_disc.as_ref() { 170 continue; 171 } 172 173 let Some(captures) = DISC_RE.captures(file_name) else { continue }; 174 175 let disc_number = captures.get(1).unwrap(); 176 let disc_name = format!("Disc {}", disc_number.as_str()); 177 discs.push((disc_name, entry.path())); 178 } 179 180 discs.sort_by(|a, b| a.0.cmp(&b.0)); 181 182 discs 183} 184 185#[derive(Debug)] 186pub struct RomListThreadHandle { 187 scan_requests_sender: Sender<Vec<RomSearchDirectory>>, 188 scan_request_counter: Arc<AtomicU32>, 189} 190 191impl RomListThreadHandle { 192 pub fn spawn(rom_list: Arc<Mutex<Vec<RomMetadata>>>, egui_ctx: egui::Context) -> Self { 193 let (scan_requests_sender, scan_requests_receiver) = 194 mpsc::channel::<Vec<RomSearchDirectory>>(); 195 let scan_request_counter = Arc::new(AtomicU32::new(0)); 196 let scan_request_counter_handle = Arc::clone(&scan_request_counter); 197 198 thread::spawn(move || { 199 while let Ok(scan_request) = scan_requests_receiver.recv() { 200 let new_rom_list = build(&scan_request); 201 *rom_list.lock().unwrap() = new_rom_list; 202 203 scan_request_counter.fetch_sub(1, Ordering::SeqCst); 204 egui_ctx.request_repaint(); 205 } 206 }); 207 208 Self { scan_requests_sender, scan_request_counter: scan_request_counter_handle } 209 } 210 211 pub fn request_scan(&self, scan_request: Vec<RomSearchDirectory>) { 212 self.scan_request_counter.fetch_add(1, Ordering::SeqCst); 213 self.scan_requests_sender.send(scan_request).unwrap(); 214 } 215 216 pub fn any_scans_in_progress(&self) -> bool { 217 self.scan_request_counter.load(Ordering::SeqCst) != 0 218 } 219}