//! The import tool's backend: scan an arbitrary folder, keep the review //! session across restarts, and apply the result by COPYING into the managed //! library root. //! //! Three rules carried over from the prototype (research/import/, gitignored) //! and the recon that preceded it: //! //! 1. The user's folder is only ever read from. Apply copies; it never //! moves, renames or rewrites the source. "If an upgrade turns out to be //! the wrong record, the original is still sitting there." (A record //! whose review kept the FOUND copy instead is moved out of the audition //! pen — the app's own cache, not his folder, which is still untouched.) //! 2. Registration goes straight to the library crate, NOT through //! `process_file` — whose duplicate check deletes the incoming file when //! the record already exists (pipeline.rs, the `remote_path.is_some()` //! branch). That is correct for Soulseek staging and fatal for imports. //! 3. Every progress event carries a payload. A UI cannot narrate a scan it //! cannot see. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use tauri::{Emitter, State}; use crate::config; use crate::pipeline::{self, LibraryState}; /* ------------------------------------------------------------------ scan -- */ #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ScanTrack { pub name: String, pub ext: String, pub bytes: u64, pub seconds: Option, pub bitrate: Option, pub sample_rate: Option, pub bit_depth: Option, pub embedded_art: bool, // What the file SAYS, for the review's "yours" column. pub artist: Option, pub album: Option, pub title: Option, pub track_no: Option, pub year: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ScanAlbum { /// Folder path relative to the scan root — the stable identity every /// review choice is keyed by, exactly as in the prototype. pub rel: String, pub artist_folder: String, pub album_folder: String, /// Path components below the root (2 = Artist\Album). pub depth: u32, /// Year parsed from the folder name, when it carries one. pub year: Option, /// Image files sitting loose in the folder. pub folder_art: Vec, pub tracks: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ScanResult { pub root: String, pub albums: Vec, } /// Per-file tick, throttled by TIME at the emit site (not by count — the old /// every-10th-folder emit is why the count jumped 10 at a time). Carries the /// running totals because throttling DROPS ticks: a UI accumulating per-event /// would drift, one reading totals cannot. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ScanFileProgress<'a> { done: usize, total: usize, files_done: usize, files_total: usize, rel: &'a str, file: &'a str, file_secs: Option, file_kbps: Option, file_lossless: bool, tracks_secs: u64, bytes: u64, lossless: usize, artists: usize, } /// Per-folder summary, emitted for EVERY folder — the ledger's fold-to-one- /// line moment must never be throttled away. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ScanFolderProgress<'a> { done: usize, total: usize, rel: &'a str, artist: &'a str, album: &'a str, tracks: usize, secs: u64, bytes: u64, lossless: usize, avg_kbps: Option, } const IMAGE_EXTS: [&str; 5] = ["jpg", "jpeg", "png", "bmp", "webp"]; fn lossless_ext(ext: &str) -> bool { matches!(ext, ".flac" | ".wav" | ".aiff" | ".aif" | ".ape" | ".wv") } fn folder_year(name: &str) -> Option { // (2017) - Album, 2017 - Album, Album (2017), Album [2017], Album - 2017 let re_hits = name .as_bytes() .windows(4) .enumerate() .filter_map(|(i, w)| { if w.iter().all(u8::is_ascii_digit) { name[i..i + 4].parse::().ok().filter(|y| (1900..=2100).contains(y)) } else { None } }) .collect::>(); re_hits.first().copied() } /// Generation counter: starting a scan retires every older one. An orphaned /// walk (page reload, superseded Scan click) would otherwise keep reading /// disk for minutes and braid its events into the new scan's ledger. static SCAN_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// Walk `root`, grouping audio files by their containing folder. Blocking — /// call from a blocking task. `on_file` fires per file read, `on_folder` per /// finished folder; `abort` is polled per file and ends the walk early. /// Returns the albums and whether the walk was aborted. fn scan_tree( root: &Path, abort: impl Fn() -> bool, mut on_file: impl FnMut(ScanFileProgress), mut on_folder: impl FnMut(ScanFolderProgress), ) -> (Vec, bool) { let mut files: Vec = Vec::new(); pipeline::collect_audio_files(root, &mut files, 0); // Group by parent dir, preserving discovery order. let mut order: Vec = Vec::new(); let mut by_dir: std::collections::HashMap> = std::collections::HashMap::new(); for f in files { let dir = f.parent().unwrap_or(root).to_path_buf(); by_dir.entry(dir.clone()).or_insert_with(|| { order.push(dir.clone()); Vec::new() }); by_dir.get_mut(&f.parent().unwrap_or(root).to_path_buf()).unwrap().push(f); } let total = order.len(); let files_total: usize = by_dir.values().map(Vec::len).sum(); let mut files_done = 0usize; let mut tracks_secs = 0u64; let mut bytes_running = 0u64; let mut lossless_running = 0usize; let mut artist_set: std::collections::HashSet = std::collections::HashSet::new(); let mut albums = Vec::with_capacity(total); for (i, dir) in order.iter().enumerate() { let rel_path = dir.strip_prefix(root).unwrap_or(dir); let rel = rel_path.to_string_lossy().into_owned(); let comps: Vec = rel_path .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect(); let depth = comps.len() as u32; let artist_folder = comps.first().cloned().unwrap_or_default(); let album_folder = comps.last().cloned().unwrap_or_default(); artist_set.insert(artist_folder.clone()); let mut folder_art = Vec::new(); if let Ok(entries) = std::fs::read_dir(dir) { for e in entries.flatten() { let p = e.path(); if p.is_file() && p.extension() .and_then(|x| x.to_str()) .map(|x| IMAGE_EXTS.contains(&x.to_ascii_lowercase().as_str())) .unwrap_or(false) { folder_art.push(e.file_name().to_string_lossy().into_owned()); } } } let mut tracks = Vec::new(); let mut paths = by_dir.remove(dir).unwrap_or_default(); paths.sort(); for p in paths { if abort() { return (albums, true); } let bytes = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0); let ext = p .extension() .and_then(|e| e.to_str()) .map(|e| format!(".{}", e.to_ascii_lowercase())) .unwrap_or_default(); let name = p.file_name().unwrap_or_default().to_string_lossy().into_owned(); // Header-level read; a failed parse still lists the file. let s = tagger::read_summary(&p).unwrap_or_default(); let file_lossless = lossless_ext(&ext); files_done += 1; tracks_secs += u64::from(s.duration_secs.unwrap_or(0)); bytes_running += bytes; if file_lossless { lossless_running += 1; } on_file(ScanFileProgress { done: i, total, files_done, files_total, rel: &rel, file: &name, file_secs: s.duration_secs, file_kbps: s.bitrate_kbps, file_lossless, tracks_secs, bytes: bytes_running, lossless: lossless_running, artists: artist_set.len(), }); tracks.push(ScanTrack { name, ext, bytes, seconds: s.duration_secs, bitrate: s.bitrate_kbps, sample_rate: s.sample_rate, bit_depth: s.bit_depth, embedded_art: s.has_art, artist: s.artist, album: s.album, title: s.title, track_no: s.track_no, year: s.year, }); } let f_secs: u64 = tracks.iter().map(|t| u64::from(t.seconds.unwrap_or(0))).sum(); let f_bytes: u64 = tracks.iter().map(|t| t.bytes).sum(); let f_lossless = tracks.iter().filter(|t| lossless_ext(&t.ext)).count(); let rates: Vec = tracks.iter().filter_map(|t| t.bitrate).collect(); let avg_kbps = (!rates.is_empty()).then(|| rates.iter().sum::() / rates.len() as u32); on_folder(ScanFolderProgress { done: i + 1, total, rel: &rel, artist: &artist_folder, album: &album_folder, tracks: tracks.len(), secs: f_secs, bytes: f_bytes, lossless: f_lossless, avg_kbps, }); let year = folder_year(&album_folder); albums.push(ScanAlbum { rel, artist_folder, album_folder, depth, year, folder_art, tracks, }); } (albums, false) } /// Scan an arbitrary folder. Emits `import:scan` per file (throttled by time) /// and `import:scan-folder` per folder, and returns the full result. /// Read-only: nothing is written anywhere. #[tauri::command] pub async fn import_scan(app: tauri::AppHandle, root: String) -> Result { let root_path = PathBuf::from(&root); if !root_path.is_dir() { return Err(format!("not a folder: {root}")); } let emitter = app.clone(); let gen = SCAN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; let (albums, aborted) = tauri::async_runtime::spawn_blocking(move || { let mut last: Option = None; scan_tree( &root_path, || SCAN_GEN.load(std::sync::atomic::Ordering::SeqCst) != gen, |p| { // ~20 ticks/s regardless of file rate; totals ride along so // dropped ticks cost nothing. if last.is_none_or(|t| t.elapsed() >= std::time::Duration::from_millis(50)) { last = Some(std::time::Instant::now()); let _ = emitter.emit("import:scan", p); } }, |p| { let _ = emitter.emit("import:scan-folder", p); }, ) }) .await .map_err(|e| e.to_string())?; if aborted { return Err("scan superseded by a newer scan".into()); } Ok(ScanResult { root, albums }) } /* --------------------------------------------------------------- session -- */ /// The saved review session, opaque to the backend: the frontend owns the /// shape (choices keyed by rel, position, step — the prototype's S), the /// backend owns durability. A file beats localStorage because it survives /// webview cache clears and travels with app data. #[tauri::command] pub fn import_session_get(app: tauri::AppHandle) -> Result, String> { let path = session_path(&app)?; match std::fs::read_to_string(path) { Ok(s) => Ok(Some(s)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.to_string()), } } #[tauri::command] pub fn import_session_set(app: tauri::AppHandle, state: String) -> Result<(), String> { let path = session_path(&app)?; std::fs::write(path, state).map_err(|e| e.to_string()) } #[tauri::command] pub fn import_session_clear(app: tauri::AppHandle) -> Result<(), String> { // The run is over, so every version still penned is one the review saw // and did not keep — the free-flip window closes with the session that // opened it. (Claimed ones moved into the library during apply.) let cfg = config::load(&app); let pen = config::download_dir(&app, &cfg).join("(Cache)").join("_audition"); if pen.is_dir() { if let Err(e) = std::fs::remove_dir_all(&pen) { log::warn!("import: could not clear the audition pen: {e}"); } } let path = session_path(&app)?; match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e.to_string()), } } /// Where the source folder sits RELATIVE to the library folder, when that can /// be said at all. Saved beside the absolute path so a session can find its /// source again after the drive is carried somewhere else. /// /// "I moved the whole folder" is the case this serves: source and library keep /// their arrangement, only the prefix changes. A source on a different drive /// from the library has no relative form and gets `None` — its absolute path /// is the only thing that could ever have identified it. #[tauri::command] pub fn import_portable_root(app: tauri::AppHandle, root: String) -> Option { let cfg = config::load(&app); relative_to(&config::download_dir(&app, &cfg), Path::new(&root)) } fn relative_to(from: &Path, to: &Path) -> Option { let key = |c: &std::path::Component| c.as_os_str().to_string_lossy().to_lowercase(); let (a, b): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect()); // Different drives (or one relative, one not) share no prefix worth having. if a.first().map(key) != b.first().map(key) { return None; } let common = a .iter() .zip(b.iter()) .take_while(|(x, y)| key(x) == key(y)) .count(); let mut out = PathBuf::new(); for _ in common..a.len() { out.push(".."); } for c in &b[common..] { out.push(c.as_os_str()); } Some(out.to_string_lossy().into_owned()) } /// The source folder this session was reviewing, as it exists NOW. /// /// Tries the path as saved first — nothing has moved in the common case. Then /// the relative form against the current library folder, which is what makes a /// review started at one machine resumable at another. `None` means the folder /// is genuinely not there and only the user can say where it went. #[tauri::command] pub fn import_resolve_root( app: tauri::AppHandle, saved: Option, rel: Option, ) -> Option { if let Some(s) = saved.as_deref() { if Path::new(s).is_dir() { return Some(s.to_string()); } } let rel = rel?; let cfg = config::load(&app); let mut candidate = config::download_dir(&app, &cfg); for part in rel.split(['/', '\\']) { match part { "" | "." => {} ".." => { candidate.pop(); } p => candidate.push(p), } } candidate .is_dir() .then(|| candidate.to_string_lossy().into_owned()) } /// In the LIBRARY folder, not AppData: an import saved at one machine and /// carried to another used to arrive with the audio intact and every decision /// gone, because the review's session never left the machine that made it. fn session_path(app: &tauri::AppHandle) -> Result { let cfg = config::load(app); let dir = config::ensure_library_data_dir(app, &cfg).map_err(|e| e.to_string())?; Ok(dir.join("import-session.json")) } /* ----------------------------------------------------------- destination -- */ #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ImportDest { pub dir: Option, /// Proposed default while no dir is chosen: the downloads root, which is /// already the app's managed library — downloads are organized into it /// with the library layout and the startup scan indexes it. Nothing is /// created until it is accepted. pub suggested: Option, pub free_bytes: Option, pub layout: String, } /// The managed root plus its real free space — shown beside "108 GB to /// copy", because the number is only useful if it is true. #[tauri::command] pub fn import_get_dest(app: tauri::AppHandle) -> Result { let cfg = config::load(&app); // `dir` is the destination that WILL be written to, never null. It used to // report only an explicitly-chosen folder, so a null read as "no // destination" and every import path disabled itself — the review's Import // button greyed out and the picker's save-and-bank returned in silence. let dir = config::import_dest_dir(&app, &cfg); let suggested = cfg .library_dir .as_deref() .map_or_else(|| Some(dir.to_string_lossy().into_owned()), |_| None); Ok(ImportDest { free_bytes: free_space_of(&dir), dir: Some(dir.to_string_lossy().into_owned()), suggested, layout: config::library_layout(&cfg).to_string(), }) } #[tauri::command] pub fn import_set_dest( app: tauri::AppHandle, dir: String, layout: Option, ) -> Result { let path = PathBuf::from(&dir); std::fs::create_dir_all(&path).map_err(|e| format!("cannot create {dir}: {e}"))?; let mut cfg = config::load(&app); // The downloads root is a legal — even natural — choice: it already IS // the managed library (organize_download files into it with the library // layout, the startup scan indexes it). Its maintenance never harms // imports: prune_* only drop DB rows, sweep_empty_dirs only removes // empty folders, and process_file's duplicate-delete branch is download- // only (remote_path is Some). Apply registers every copy the moment it // lands, so an imported file is never "unknown" to the scan. cfg.library_dir = Some(dir); if let Some(l) = layout { let trimmed = l.trim().to_string(); cfg.library_layout = (!trimmed.is_empty()).then_some(trimmed); } config::save(&app, &cfg); import_get_dest(app) } /// Free space on the disk holding `path`, via the sysinfo dep the crate /// already carries — shown beside "108 GB to copy", because the number is /// only useful if it is true. fn free_space_of(path: &Path) -> Option { let disks = sysinfo::Disks::new_with_refreshed_list(); // canonicalize returns a \\?\-verbatim path on Windows, which never // starts_with sysinfo's plain "R:\" mount point — strip it, or every // lookup silently reports "unknown". let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let canon_str = canon.to_string_lossy(); let plain = PathBuf::from( canon_str.strip_prefix(r"\\?\").unwrap_or(&canon_str).to_string(), ); disks .iter() .filter(|d| plain.starts_with(d.mount_point())) .max_by_key(|d| d.mount_point().as_os_str().len()) .map(|d| d.available_space()) } /* ------------------------------------------------------------- local art -- */ /// The picker's "yours" tile: the record's own artwork, measured — decoded /// dimensions, never a filename's claim. Embedded front cover first (the /// review established that is where the real covers live), else the largest /// loose image in the folder, with Windows Media Player's derived /// thumbnails (AlbumArt*, Folder.jpg's tiny siblings) skipped unless they /// are all there is. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalArt { pub path: String, pub w: u32, pub h: u32, /// "embedded" | "folder" | "derived" (a WMP thumbnail was all we had) pub kind: String, /// The file the pixels came from. pub from: String, /// WMP thumbnails skipped over. pub derived_ignored: u32, } #[tauri::command] pub async fn import_local_art( app: tauri::AppHandle, source_root: String, rel: String, parts: Option>, ) -> Result, String> { let parts = parts.unwrap_or_default(); tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel, &parts)) .await .map_err(|e| e.to_string()) } /// One folder's audio, sorted, and NOT what is underneath it. fn audio_in_dir(dir: &Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return }; let mut here: Vec = entries .flatten() .map(|e| e.path()) .filter(|p| p.is_file() && audio_formats::is_audio_path(p)) .collect(); here.sort(); out.append(&mut here); } /// The audio a record actually OWNS: its own folder, plus the part folders the /// review named — in part order, sorted within each part. The same walk apply /// does, and for the same reason ([`ApplyAlbum::parts`]). /// /// It is deliberately not recursive. A recursive walk is how the picker came to /// show a NEIGHBOUR's cover as "yours": an artist folder holding one loose file /// beside its albums — `Sweet\` is a single 52 MB "So Serene" rip, while /// `Sweet\Sweet Valley - SV [2013]\` is a separate 12-track record — collected /// both sets, and sorting the full paths put the subfolder first ('V' 0x56 /// before 'o' 0x6F), so the record was shown the other record's artwork. That /// picture also became the record's `art_dhash`, which is what the discography /// pass compares against the Cover Art Archive. fn own_audio_files(dir: &Path, parts: &[String]) -> Vec { let mut out = Vec::new(); if parts.is_empty() { audio_in_dir(dir, &mut out); } else { for part in parts { // An empty entry is the record's own folder — a container holding // loose audio beside its parts. if part.is_empty() { audio_in_dir(dir, &mut out); } else { audio_in_dir(&dir.join(part), &mut out); } } } out } fn local_art_of( app: &tauri::AppHandle, source_root: &str, rel: &str, parts: &[String], ) -> Option { let dir = Path::new(source_root).join(rel); if !dir.is_dir() { return None; } // Embedded front cover of the first files that carry one — materialized // into app data so the webview can read it (the audio file itself can't // be handed to an ). let files = own_audio_files(&dir, parts); for f in files.iter().take(5) { let Ok(Some(bytes)) = tagger::extract_art(f) else { continue }; let Ok(img) = image::load_from_memory(&bytes) else { continue }; let (w, h) = (img.width(), img.height()); // In the library folder: these are the covers the review is choosing // BETWEEN, and a choice made at one machine has to still be there when // the drive is opened at another. // v2: entries under the old name were extracted by a RECURSIVE walk, so // a record sharing a folder with another one cached the neighbour's // cover — and `if !target.exists()` below means a stale entry is never // rewritten. Versioning the directory retires all of them at once, // which is what `cover-logos-v3` and `artist-identity-v1` do. let cache_dir = config::ensure_library_data_dir(app, &config::load(app)) .ok()? .join("import-art-v2"); std::fs::create_dir_all(&cache_dir).ok()?; // Stable name per rel so reopening a record reuses the extraction. let mut hash: u64 = 0xcbf29ce484222325; for b in rel.as_bytes() { hash ^= u64::from(*b); hash = hash.wrapping_mul(0x100000001b3); } let target = cache_dir.join(format!("{hash:016x}.jpg")); if !target.exists() { std::fs::write(&target, &bytes).ok()?; } return Some(LocalArt { path: target.to_string_lossy().into_owned(), w, h, kind: "embedded".into(), from: f.file_name().unwrap_or_default().to_string_lossy().into_owned(), derived_ignored: 0, }); } // Loose images in the folder. WMP's derived thumbnails are recognizable // by name; they only qualify when nothing else exists. let mut real: Vec = Vec::new(); let mut derived: Vec = Vec::new(); for e in std::fs::read_dir(&dir).ok()?.flatten() { let p = e.path(); let is_img = p.is_file() && p.extension() .and_then(|x| x.to_str()) .map(|x| IMAGE_EXTS.contains(&x.to_ascii_lowercase().as_str())) .unwrap_or(false); if !is_img { continue; } let name = p.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); if name.starts_with("albumart") { derived.push(p); } else { real.push(p); } } let derived_ignored = derived.len() as u32; let (pool, kind) = if real.is_empty() { (derived, "derived") } else { (real, "folder") }; pool.into_iter() .filter_map(|p| { let (w, h) = image::image_dimensions(&p).ok()?; Some((p, w, h)) }) .max_by_key(|(_, w, h)| u64::from(*w) * u64::from(*h)) .map(|(p, w, h)| LocalArt { from: p.file_name().unwrap_or_default().to_string_lossy().into_owned(), path: p.to_string_lossy().into_owned(), w, h, kind: kind.into(), derived_ignored: if kind == "folder" { derived_ignored } else { 0 }, }) } /// A record's own artwork, in the order the review means it: embedded in its /// files first, then the best loose image sitting beside them. /// /// Mirrors [`local_art_of`], which is what the picker shows as "yours" — the /// two must agree, or the review offers a cover apply cannot file. fn art_of(dir: &Path, files: &[PathBuf]) -> Option> { for f in files.iter().take(5) { if let Ok(Some(bytes)) = tagger::extract_art(f) { return Some(bytes); } } folder_image_of(dir).and_then(|p| std::fs::read(&p).ok()) } /// The best loose image in a record's own folder, largest first, WMP's derived /// thumbnails only when nothing else is there. /// /// Shared with [`local_art_of`]'s rules on purpose: the picker offers this /// picture as "yours", so apply has to be able to file the same one. Apply /// used to look only for EMBEDDED art, which meant a record whose artwork /// lives in a `Folder.jpg` was shown a cover in the review, accepted with /// "yours", and then imported with no cover at all. fn folder_image_of(dir: &Path) -> Option { let mut real: Vec = Vec::new(); let mut derived: Vec = Vec::new(); for e in std::fs::read_dir(dir).ok()?.flatten() { let p = e.path(); let is_img = p.is_file() && p.extension() .and_then(|x| x.to_str()) .map(|x| IMAGE_EXTS.contains(&x.to_ascii_lowercase().as_str())) .unwrap_or(false); if !is_img { continue; } let name = p.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); if name.starts_with("albumart") { derived.push(p); } else { real.push(p); } } let pool = if real.is_empty() { derived } else { real }; pool.into_iter() .filter_map(|p| { let (w, h) = image::image_dimensions(&p).ok()?; Some((p, u64::from(w) * u64::from(h))) }) .max_by_key(|(_, px)| *px) .map(|(p, _)| p) } /* -------------------------------------------------------------- audition -- */ /// Transfer ids belonging to audition downloads, each mapped to the record it /// is for and its position in that record's file list. The completion handler /// must NOT run these through process_file — they are candidate versions /// under review in the `(Cache)\_audition` pen, not library arrivals. /// /// The mapping carries `(rel, index)` rather than being a bare set because the /// UI has to match events to a record, and it CANNOT do that by id: the /// transfers begin — and emit Connecting, Requested and Queued — inside /// `import_audition`, before the command returns the ids the front end would /// look them up in. Every one of those early events was dropped, and since the /// client emits nothing further while a transfer sits in a peer's queue, the /// row had no news to show for as long as the wait lasted. pub(crate) static AUDITION_IDS: std::sync::LazyLock< std::sync::Mutex>, > = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); pub(crate) fn is_audition(id: u64) -> bool { AUDITION_IDS.lock().unwrap().contains_key(&id) } /// Which record and which position, for an event that is about to be emitted. /// Look this up BEFORE `audition_done` retires the id. pub(crate) fn audition_of(id: u64) -> Option<(String, usize)> { AUDITION_IDS.lock().unwrap().get(&id).cloned() } pub(crate) fn audition_done(id: u64) { AUDITION_IDS.lock().unwrap().remove(&id); } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuditionFile { pub path: String, pub size: u64, } /// The pen one record's found copy downloads into. DERIVED from `rel` on /// both sides — the download and the apply-time move — so a session resumed /// days later cannot hand the move a path the transfer never wrote to. pub(crate) fn audition_dir(download_root: &Path, rel: &str) -> PathBuf { let flat = rel.replace(['/', '\\'], " - "); download_root .join("(Cache)") .join("_audition") .join(pipeline::safe_component(&flat)) } /// Where one of a found copy's files lands in the pen. Shared by the download /// and by the completeness check below, so the two can never disagree about /// which file on disk is which track. fn pen_target(base: &Path, remote_path: &str) -> PathBuf { let leaf = remote_path.rsplit('\\').next().unwrap_or(remote_path); base.join(pipeline::safe_component(leaf)) } /// Which of a found copy's files the pen does not (fully) hold, by index into /// `files`. /// /// Transfers do not survive the app; the files they wrote do. A restored /// audition therefore LOOKS live and is not, and its arrived-count is a /// running total kept by events that stopped when the process did — after a /// crash, a power cut or a restart it is a number about a session that no /// longer exists. The pen is the only witness left, so it is what gets asked. /// /// A SHORT file counts as missing: a transfer killed mid-write leaves a /// partial behind, and an existence check would call that track arrived and /// go on to import a truncated one. #[tauri::command] pub async fn import_audition_missing( app: tauri::AppHandle, rel: String, files: Vec, ) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { let cfg = config::load(&app); let base = audition_dir(&config::download_dir(&app, &cfg), &rel); missing_in_pen(&base, &files) }) .await .map_err(|e| e.to_string()) } /// Empty one record's pen, and report the bytes it freed. /// /// Called when the review ABANDONS a peer. Switching cancels that peer's /// transfers, but it cannot un-write the files they already delivered, and the /// pen is keyed by record rather than by peer — so an abandoned copy sat there /// until the whole session was cleared, 189 MB of a stranger's Centipede Hz /// among them. Apply no longer files those (it takes the chosen copy's own /// files by name), so this is about disk, not correctness. /// /// The path is DERIVED from `rel` here, exactly as the download and the /// apply-time move derive it. Nothing outside the pen can be named, whatever /// the caller passes. #[tauri::command] pub async fn import_audition_clear(app: tauri::AppHandle, rel: String) -> Result { tauri::async_runtime::spawn_blocking(move || { let cfg = config::load(&app); let dir = audition_dir(&config::download_dir(&app, &cfg), &rel); if !dir.is_dir() { return Ok(0); } let freed: u64 = std::fs::read_dir(&dir) .into_iter() .flatten() .flatten() .filter_map(|e| e.metadata().ok()) .filter(|m| m.is_file()) .map(|m| m.len()) .sum(); std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; Ok(freed) }) .await .map_err(|e| e.to_string())? } /// The rule itself, without an app handle so it can be tested. A file the pen /// does not hold is missing; so is one it holds only part of. A size the peer /// never stated (0) cannot be checked against, and a file that is there is /// taken at its word rather than called missing forever. fn missing_in_pen(base: &Path, files: &[AuditionFile]) -> Vec { files .iter() .enumerate() .filter(|(_, f)| match std::fs::metadata(pen_target(base, &f.path)) { Ok(m) => f.size > 0 && m.len() < f.size, Err(_) => true, }) .map(|(i, _)| i) .collect() } /// Download a found album copy into `(Cache)\_audition\\` for the /// version comparison — the WHOLE album (his call: keeping it later is a /// move, not a re-download, and one search's worth of requests). /// /// Called TWICE per copy: track 1 alone, then the rest once it lands. Asking /// for all twelve at once put eleven of our own requests in front of the one /// file the comparison needs — the peer serves its queue in its own order, so /// "track 1 first" was only ever true of the list we sent. `start_index` keeps /// the second batch's indices continuing from the first, because the UI reads /// index 0 as "this is track 1". /// /// Returns transfer ids aligned with the input files. #[tauri::command] pub async fn import_audition( app: tauri::AppHandle, state: State<'_, crate::state::AppState>, username: String, rel: String, files: Vec, start_index: Option, ) -> Result, String> { let guard = state.client.lock().await; let client = guard.as_ref().ok_or("not connected")?; let cfg = config::load(&app); let base = audition_dir(&config::download_dir(&app, &cfg), &rel); std::fs::create_dir_all(&base).map_err(|e| e.to_string())?; let mut ids = Vec::with_capacity(files.len()); for (i, f) in files.iter().enumerate() { let target = pen_target(&base, &f.path); // Claimed BEFORE the transfer task is spawned: it emits Connecting // immediately, and an event whose id is not yet registered is one the // UI throws away — and worse, one commands.rs routes down the ordinary // download path instead of the pen's. let idx = start_index.unwrap_or(0) + i; let id = client.download_to_claimed(&username, &f.path, f.size, target, |id| { AUDITION_IDS.lock().unwrap().insert(id, (rel.clone(), idx)); }); ids.push(id); } Ok(ids) } /// Measured facts about one audio file — the version card's quality chips /// read the arrived audition file itself, never the search response's /// claims. Read-only. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProbeResult { pub seconds: Option, pub bitrate: Option, pub sample_rate: Option, pub bit_depth: Option, pub bytes: u64, pub title: Option, } #[tauri::command] pub async fn import_probe_file(path: String) -> Result { tauri::async_runtime::spawn_blocking(move || { let p = PathBuf::from(&path); let bytes = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0); let s = tagger::read_summary(&p).map_err(|e| e.to_string())?; Ok(ProbeResult { seconds: s.duration_secs, bitrate: s.bitrate_kbps, sample_rate: s.sample_rate, bit_depth: s.bit_depth, bytes, title: s.title, }) }) .await .map_err(|e| e.to_string())? } /* -------------------------------------------------------------- identify -- */ /// Identify one record against the providers, on demand when its card is /// opened — never a batch (decision 12). Deezer down the cleaned try-list, /// MusicBrainz when Deezer's answer is absent or wrong; the crate holds the /// whole flow (metadata::identify_album). Read-only. /// /// `title` is what the record calls ITSELF (the tag majority); `folder` is the /// folder it sits in, asked after it as the second witness for a record whose /// tags are wrong (metadata::identify_titles). /// /// The rest is the record's SHAPE, which is what the discography pass compares /// when no phrase can find it: how many files it has, what they are called, the /// year, and — read here rather than shipped over IPC, because a master cover /// can be 8 MB — the perceptual hash of its own embedded artwork. #[tauri::command] pub async fn import_identify( app: tauri::AppHandle, state: State<'_, LibraryState>, artist: String, title: String, folder: Option, tracks: Option, track_titles: Option>, year: Option, source_root: Option, rel: Option, parts: Option>, ) -> Result { // The record's OWN parts, so the hash is its own cover — a record sharing a // folder with another one used to hash the neighbour's. let parts = parts.unwrap_or_default(); let art_dhash = match (source_root.as_deref(), rel.as_deref()) { (Some(root), Some(rel)) => local_art_dhash(&app, root, rel, &parts).await, _ => None, }; let shape = metadata::RecordShape { tracks, track_titles: track_titles.unwrap_or_default(), year, art_dhash, }; Ok(state .metadata .identify_album(&artist, &title, folder.as_deref(), &shape) .await) } /// The perceptual hash of a record's OWN cover, for comparison against an /// artist's discography. Reuses `local_art_of`, so it reads the extraction that /// the artwork panel already cached rather than decoding the audio again. async fn local_art_dhash( app: &tauri::AppHandle, source_root: &str, rel: &str, parts: &[String], ) -> Option { let art = { let app = app.clone(); let (root, rel) = (source_root.to_string(), rel.to_string()); let parts = parts.to_vec(); tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &root, &rel, &parts)) .await .ok()?? }; let bytes = tokio::fs::read(&art.path).await.ok()?; metadata::cover_dhash(&bytes) } /// The artwork panel's data: reference + candidates with decoded sizes and /// same-artwork dHash flags. `reference_url` is the identified candidate's /// cover — the picker compares everything against the record it matched. #[tauri::command] pub async fn import_cover_report( state: State<'_, LibraryState>, artist: String, title: String, reference_url: String, ) -> Result { Ok(state .metadata .cover_report(&artist, &title, &reference_url) .await) } /* ----------------------------------------------------------------- apply -- */ #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyAlbum { pub rel: String, /// The values the review settled on — whatever side won each field. pub artist: String, pub album: String, pub year: Option, /// 4c.3 — the picker's resolved departures for this record. Absent on a /// record never opened (phase-1 behaviour applies unchanged). #[serde(default)] pub choices: Option, /// A release the scan met as its parts (`Analord 01`…`Analord 11`, /// `Disc 1`/`Disc 2`): the sub-folders under `rel` holding the audio, in /// the order the review showed them. Empty for an ordinary record, whose /// audio is in its own folder. /// /// An EXPLICIT list, never a recursive walk. Recursion is what let one /// stray file at the top of an artist folder swallow 288 descendants and /// duplicate 9 GB; the fold is decided in the review, where it is shown /// and can be overruled, and apply only ever reads the folders named here. /// An empty string entry means the record's own folder — a container that /// holds loose audio beside its parts. #[serde(default)] pub parts: Vec, } /// What the review resolved, as VALUES — the backend never re-derives a /// side. File-tag writes ride TagWrite's Option semantics: None = leave the /// copied file's field alone. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyChoices { /// Provider cover to fetch and file as the album's art (and embed into /// copies that carry none). None keeps the local extraction. pub art_url: Option, pub artist: Option, pub album: Option, pub year: Option, pub label: Option, pub genre: Option, /// Chosen provider titles by track position (aligned to the sorted file /// list, same order the scan showed); None entries keep yours. pub track_titles: Option>>, /// Which VERSION of the album the library keeps: `"theirs"` means the /// copy cached in the audition pen replaces the user's files as the /// thing that gets imported. Absent or `"yours"` = the source folder, /// which is phase-1 behaviour unchanged. pub audio: Option, /// How many tracks the found copy was supposed to bring. The pen holding /// fewer than this means the transfer never finished, and importing it /// would file a truncated album — apply refuses instead. pub audio_tracks: Option, /// The chosen copy's own files, as the peer named them. The pen is keyed /// by RECORD, not by peer, so a record that changed peers mid-audition has /// TWO copies in one folder: switching cancels the old transfers but /// cannot un-write the files they already delivered. Taking "every audio /// file in the pen" then files both — Centipede Hz landed as 17 tracks, /// deletia's 11 plus the 6 smash2 got through before it was dropped. /// /// None (an older session, or "yours") keeps the previous behaviour. pub audio_files: Option>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ApplyReport { pub albums: usize, pub tracks: usize, pub bytes: u64, /// Records nothing was written for, WITH their identity — the caller has /// to know which ones so it does not mark them done. A flat /// "{rel}: {reason}" string forced the UI to parse prose to find that out, /// and it did not, so a refused record was banked as imported. pub skipped: Vec, /// Files of a previous version deleted because the review chose the found /// copy instead. Reported so a replacement is never silent. pub replaced: usize, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkippedAlbum { pub rel: String, pub album: String, pub reason: String, } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ApplyProgress<'a> { done: usize, total: usize, album: &'a str, } /// Copy the chosen records into the managed root and register them. The /// source tree is never written to; a failed album is reported and skipped, /// never half-registered. #[tauri::command] pub async fn import_apply( app: tauri::AppHandle, state: State<'_, LibraryState>, source_root: String, albums: Vec, ) -> Result { let cfg = config::load(&app); // An unset destination means the library folder, not a refusal — see // `config::import_dest_dir`. let dest_root = config::import_dest_dir(&app, &cfg); let layout = config::library_layout(&cfg).to_string(); let embed_max_edge = config::embed_art_max_edge(&cfg); let replace_embedded = config::replace_embedded_art(&cfg); let src_root = PathBuf::from(&source_root); // Where a kept found-copy is moved FROM. The download root, not the // library root: the pen lives beside the transfers that filled it. let download_root = config::download_dir(&app, &cfg); let library = state.library.clone(); // Chosen provider covers are fetched up front (network is async, the // copy loop is blocking); a failed fetch falls back to local extraction // rather than failing the album. let mut chosen_art: std::collections::HashMap> = std::collections::HashMap::new(); for a in &albums { if let Some(url) = a.choices.as_ref().and_then(|c| c.art_url.as_deref()) { if let Some(bytes) = state.metadata.fetch_cover_bytes(url).await { chosen_art.insert(a.rel.clone(), bytes); } else { log::warn!("import: chosen cover fetch failed for {}: {url}", a.rel); } } } let emitter = app.clone(); let total = albums.len(); let report = tauri::async_runtime::spawn_blocking(move || { let mut done_albums = 0usize; let mut done_tracks = 0usize; let mut done_bytes = 0u64; let mut done_replaced = 0usize; let mut skipped = Vec::new(); for (i, a) in albums.iter().enumerate() { match apply_one( &library, &src_root, &dest_root, &download_root, &layout, a, chosen_art.get(&a.rel).map(Vec::as_slice), embed_max_edge, replace_embedded, ) { Ok((t, b, r)) => { done_albums += 1; done_tracks += t; done_bytes += b; done_replaced += r; } Err(e) => skipped.push(SkippedAlbum { rel: a.rel.clone(), album: a.album.clone(), reason: e, }), } let _ = emitter.emit( "import:apply", ApplyProgress { done: i + 1, total, album: &a.album }, ); } ApplyReport { albums: done_albums, tracks: done_tracks, bytes: done_bytes, skipped, replaced: done_replaced, } }) .await .map_err(|e| e.to_string())?; let _ = app.emit("soulseek:library-changed", ()); Ok(report) } /// The audio files a record OWNS — the ones directly in its folder, and only /// those. /// /// This must not recurse, and the difference is not cosmetic. The scan groups /// files by their IMMEDIATE parent, so a folder holding both loose tracks and /// album subfolders becomes one small record plus a record per subfolder. A /// recursive collect here made that small record swallow the entire tree: /// `Animal Collective` (one loose file, 42 album subfolders) copied all 288 /// descendants, flattened, into `Animal Collective/2021 - Prester John/`, and /// every one of those albums was ALSO imported separately — the same files /// written twice, into two different places, under one 4.3 GB album row. fn audio_files_in(dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); }; entries .flatten() .map(|e| e.path()) .filter(|p| p.is_file() && pipeline::is_audio_path(p)) .collect() } #[allow(clippy::too_many_arguments)] fn apply_one( library: &library::Library, src_root: &Path, dest_root: &Path, download_root: &Path, layout: &str, a: &ApplyAlbum, chosen_art: Option<&[u8]>, embed_max_edge: u32, replace_embedded: bool, ) -> Result<(usize, u64, usize), String> { // The version the review settled on decides WHICH folder is the source. // Everything downstream — layout, art, tags, registration — is identical // either way; only the bytes come from somewhere else. let take_theirs = a.choices.as_ref().and_then(|c| c.audio.as_deref()) == Some("theirs"); let pen_dir = audition_dir(download_root, &a.rel); let src_dir = if take_theirs { pen_dir.clone() } else { src_root.join(&a.rel) }; // The destination folder, from the SAME renderer downloads use — one // layout vocabulary, not two. Its PATH is computed before the source is // judged, because for a kept found-copy the destination is where the // pen's tracks have ALREADY gone if this record was banked earlier — but // it is not CREATED until this album is certain to be written. A record // refused for a half-arrived pen used to leave an empty album folder in // the library on every attempt. let rendered = pipeline::render_layout(layout, &a.artist, &a.album, "", a.year); let mut dest_dir = dest_root.to_path_buf(); for seg in rendered.split(['/', '\\']) { let seg = pipeline::safe_segment(seg); if !seg.is_empty() { dest_dir = dest_dir.join(seg); } } // A folded release gathers from its named parts, in part order, sorted // within each part — NOT sorted across the whole set, which would // interleave volume 2 track 1 between volume 1's tracks. The order has to // match the one the review displayed, because `track_titles` is // positional: disagree here and every title past the first part lands on // the wrong file. A found copy overrides this — the pen is flat. // Which disc each file belongs to, aligned with `files`. Empty (all None) // for anything that is not a folded release — there, the disc is whatever // the file's own tag says, read back after the copy. let mut discs: Vec> = Vec::new(); let mut files: Vec = if take_theirs || a.parts.is_empty() { let mut f = audio_files_in(&src_dir); // A found copy is the files the REVIEW chose, not whatever the folder // happens to hold. Same derivation as the download used, so a name the // peer sent maps to exactly the file it wrote. Sorting stays where it // was: `track_titles` is positional against this order. if take_theirs { if let Some(names) = a.choices.as_ref().and_then(|c| c.audio_files.as_ref()) { if !names.is_empty() { let chosen: std::collections::HashSet = names.iter().map(|n| pen_target(&pen_dir, n)).collect(); f.retain(|p| chosen.contains(p)); } } } f.sort(); f } else { let mut out = Vec::new(); for (n, part) in a.parts.iter().enumerate() { let dir = if part.is_empty() { src_dir.clone() } else { src_dir.join(part) }; let mut f = audio_files_in(&dir); f.sort(); // The parts ARE the discs, in the order the review joined them. // Every part numbers its own tracks from 1, so without this the // flattened album reads as eleven track-1s in alphabetical order — // which is exactly what Analord's eleven volumes did. // // ONE part is not a multi-disc release: it is a container folder // whose tracks live a level down, and numbering it "disc 1" would // put a disc heading on an ordinary album. let disc = if a.parts.len() > 1 { Some(n as u32 + 1) } else { None }; discs.extend(std::iter::repeat(disc).take(f.len())); out.append(&mut f); } out }; // Re-applying a kept found-copy: "Save & finish later" banks records as // it goes, and the final apply passes every record again. The first pass // EMPTIED the pen into the library, so an absent pen is the success case, // not a failure — read the destination instead and let the loop's // same-name-same-size reuse turn the whole album into a no-op. let mut src_dir = src_dir; if take_theirs && files.is_empty() { files = audio_files_in(&dest_dir); files.sort(); if files.is_empty() { return Err("the cached copy is no longer in the pen — find it again".into()); } src_dir = dest_dir.clone(); } if !src_dir.is_dir() { return Err(if take_theirs { "the cached copy is no longer in the pen — find it again".into() } else { String::from("source folder is gone") }); } if files.is_empty() { return Err("no audio files".into()); } // A half-arrived pen would file a truncated album under the record's own // name — worse than not importing it, because it looks complete. Refuse // and say the count; the transfer can finish and apply again. if take_theirs && src_dir == pen_dir { if let Some(want) = a.choices.as_ref().and_then(|c| c.audio_tracks) { if files.len() < want { return Err(format!( "the found copy is still arriving — {} of {want} tracks cached. \ It imports as soon as the rest land; \"Keep mine\" takes your copy instead", files.len() )); } } } // Past every refusal: this album IS being written, so the folder earns // its existence now. std::fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?; // The album's art_path: the review's chosen provider cover when one was // picked, else embedded art (front cover of the first file that has one) // — the review already established that this is where real covers live. let mut art_path: Option = None; let mut art_sources: Vec> = Vec::new(); if let Some(bytes) = chosen_art { art_sources.push(bytes.to_vec()); } else { // No provider cover chosen means "keep the LOCAL artwork" — and local // is the user's OWN folder, which is where the review found the // picture it put on screen. // // This used to read whatever folder the AUDIO came from, which is a // different folder entirely once a record keeps the found copy: the // pen. A downloaded album frequently carries no embedded picture, so // keeping someone else's copy threw away the artwork the review had // just shown — three records imported with no cover at all while // their own files held a 1500px one. // // Art follows the RECORD; only the audio follows the choice. let own_dir = src_root.join(&a.rel); let mut own_files = audio_files_in(&own_dir); own_files.sort(); if let Some(bytes) = art_of(&own_dir, &own_files).or_else(|| art_of(&src_dir, &files)) { art_sources.push(bytes); } } // The master cover is the album folder's cover.jpg — filed into the // DESTINATION before the audio is copied in beside it. for bytes in art_sources.iter().take(1) { if let Some(cover) = pipeline::write_album_cover_files(&dest_dir, bytes) { art_path = Some(cover.to_string_lossy().into_owned()); } } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let album_id = library .upsert_album(&a.artist, &a.album, a.year, art_path.as_deref(), None, None, now) .map_err(|e| e.to_string())?; // The chosen cover's embed copy, downscaled ONCE per album to the same // bound the download pipeline applies (a 3000px scan must not add // megabytes to every track). let embed_art: Option> = chosen_art.map(|b| pipeline::prepare_embed_art(b.to_vec(), embed_max_edge)); // Chosen titles are indexed against the sorted list the picker showed — // YOUR files. Keeping the found copy replaces that list wholesale, so the // titles only still line up if the two albums have the same length; at a // different count position i means a different song and the write would // mislabel every track after the first divergence. let track_titles = match a.choices.as_ref().and_then(|c| c.track_titles.as_ref()) { Some(t) if take_theirs && t.len() != files.len() => { log::warn!( "import: {} keeps the found copy ({} tracks) but its chosen titles are {} long — titles left alone", a.rel, files.len(), t.len() ); None } other => other, }; let mut tracks = 0usize; let mut bytes_total = 0u64; // Every destination path this run wrote or reused, lower-cased — the set // the replacement pass below must never touch. let mut kept: std::collections::HashSet = std::collections::HashSet::new(); for (i, src) in files.iter().enumerate() { let name = src.file_name().unwrap_or_default().to_string_lossy().into_owned(); // File names are copied verbatim — his call, and what the pipeline // itself does. Collisions get " (n)" like organize_download — but a // destination file with the SAME name and size is this very track // from an earlier apply: reuse it, so re-applying is idempotent // rather than a " (2)" duplicate factory. let mut dest = dest_dir.join(pipeline::safe_component(&name)); // A file already at this path, that THIS RUN did not write, is this // very track from an earlier apply. // // Decided by name alone. It used to also require the destination to be // the same SIZE as the source, which was self-defeating: apply writes // the review's tags and embeds the chosen cover INTO the copy, so an // imported file is bigger than its source from the moment it is made // (582 KB bigger, measured on a Tribe Called Quest track). The sizes // could therefore never match again, `already` was false on every // re-run, and each import laid down another " (n)" copy — three runs // left three copies of every track on disk and three rows in the // library. The rule meant to stop a duplicate factory was driving one. // // Within ONE run a repeated name is still a genuine collision: a folded // release flattens parts that each number from 01, so `Disc 1/01.mp3` // and `Disc 2/01.mp3` land in the same folder. `kept` — what this run // has written — is what tells a collision from a reuse. let already = dest.exists() && !kept.contains(&dest.to_string_lossy().to_lowercase()); if dest.exists() && !already { let stem = dest.file_stem().unwrap_or_default().to_string_lossy().into_owned(); let ext = dest .extension() .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); for n in 2..100 { let candidate = dest_dir.join(format!("{stem} ({n}){ext}")); if !candidate.exists() { dest = candidate; break; } } } if !already { if take_theirs { // Keeping the found copy is a MOVE — the whole album already // came down the wire once, and the pen is a cache, not a // second library. Rename across volumes fails (the pen sits // under the download root, the library may not), so fall back // to copy-then-drop rather than leaving the album in limbo. if std::fs::rename(src, &dest).is_err() { std::fs::copy(src, &dest).map_err(|e| format!("move {name}: {e}"))?; let _ = std::fs::remove_file(src); } } else { std::fs::copy(src, &dest).map_err(|e| format!("copy {name}: {e}"))?; } } // The review's chosen values land on the COPY, never the source — // TagWrite's Option semantics: None leaves the field as it is. A // chosen cover embeds at the same bounded size downloads use, and // replaces an existing picture when config says so — an explicit // pick in the review is the strongest consent that config models. // The disc is STRUCTURE, not a review choice: it is what the record is // made OF, and a record nobody opened still has to come out readable. // So it is written even when there are no choices at all — and into // the FILE, because the folder that carried the answer is gone the // moment the parts are filed flat. let disc = discs.get(i).copied().flatten(); if a.choices.is_some() || disc.is_some() { let ch = a.choices.as_ref(); let write = tagger::TagWrite { artist: ch.and_then(|c| c.artist.clone()), album_artist: None, album: ch.and_then(|c| c.album.clone()), title: track_titles.and_then(|t| t.get(i).cloned().flatten()), track_no: None, disc_no: disc, year: ch.and_then(|c| c.year), label: ch.and_then(|c| c.label.clone()), genre: ch.and_then(|c| c.genre.clone()), }; let embed = embed_art.as_deref(); if let Err(e) = tagger::write_tags( &dest, &write, embed, replace_embedded && embed.is_some(), ) { log::warn!("import: tagging {} failed: {e}", dest.display()); } } let s = tagger::read_summary(&dest).unwrap_or_default(); let size = std::fs::metadata(&dest).map(|m| m.len()).ok(); let title = s .title .clone() .unwrap_or_else(|| dest.file_stem().unwrap_or_default().to_string_lossy().into_owned()); let artist = s.artist.clone().unwrap_or_else(|| a.artist.clone()); library .upsert_track( album_id, // The fold's answer first: it is the structure we just imposed // on this record. Everything else takes the file at its word. disc.or(s.disc_no), s.track_no, &title, &artist, s.duration_secs, s.bitrate_kbps, s.sample_rate, s.bit_depth, size, &dest.to_string_lossy(), now, ) .map_err(|e| e.to_string())?; kept.insert(dest.to_string_lossy().to_lowercase()); tracks += 1; bytes_total += size.unwrap_or(0); } // Replacing the version means the folder holds ONE version afterwards. // A record banked as "yours" and then flipped to "theirs" inside the // retention window (rule 5 promises that flip is free) would otherwise // leave both copies side by side, both registered. The tile says // "Replace the album with this copy", so the copy it replaced goes. // // Strictly bounded, and each bound matters: only rows THIS album owns, // only files sitting in this album's own destination folder, and never a // file this run just wrote or reused. The count is reported, never silent. let mut replaced = 0usize; if take_theirs { let dest_key = dest_dir.to_string_lossy().to_lowercase(); let mut stale = Vec::new(); for t in library.album_tracks(album_id).unwrap_or_default() { let p = PathBuf::from(&t.path); let same_folder = p .parent() .map(|d| d.to_string_lossy().to_lowercase() == dest_key) .unwrap_or(false); if !same_folder || kept.contains(&t.path.to_lowercase()) { continue; } match std::fs::remove_file(&p) { Ok(()) => replaced += 1, // Already gone is the same outcome; a locked file is not, and // dropping the row for a file still on disk would leave an // orphan the next scan re-adds. Keep the row, say so. Err(e) if e.kind() == std::io::ErrorKind::NotFound => replaced += 1, Err(e) => { log::warn!("import: could not remove the replaced {}: {e}", p.display()); continue; } } stale.push(t.id); } if !stale.is_empty() { if let Err(e) = library.remove_tracks(&stale) { log::warn!("import: {} replaced but its old rows survived: {e}", a.rel); } log::info!( "import: {} now holds the found copy — {} file(s) of the previous version removed", a.rel, replaced ); } } // The pen held a cache, and the cache has been spent: what is left is the // peer's stray .m3u/.nfo and an empty folder. Only ever the record's OWN // audition directory, never the pen root. if take_theirs && src_dir == pen_dir { if let Err(e) = std::fs::remove_dir_all(&pen_dir) { log::warn!("import: could not clear the audition pen {}: {e}", pen_dir.display()); } } Ok((tracks, bytes_total, replaced)) } #[cfg(test)] mod tests { use super::*; /// What the pen holds is what an audition actually got — and a PARTIAL /// file is not an arrival. A transfer killed mid-write (the app closing, a /// power cut) leaves a short file behind, and an existence check would /// call that track arrived, stop asking for it, and go on to import a /// truncated one. This is the check that unsticks a record whose /// transfers died with the process that was watching them. #[test] fn the_pen_counts_a_short_file_as_missing_not_arrived() { let tmp = tempfile::tempdir().unwrap(); let base = tmp.path(); let f = |name: &str, size: u64| AuditionFile { path: format!("@peer\\Music\\Album\\{name}"), size, }; let files = vec![ f("01 whole.flac", 10), f("02 partial.flac", 10), f("03 never came.flac", 10), f("04 size unstated.flac", 0), ]; std::fs::write(base.join("01 whole.flac"), b"0123456789").unwrap(); std::fs::write(base.join("02 partial.flac"), b"012").unwrap(); // 03 never arrived at all. std::fs::write(base.join("04 size unstated.flac"), b"anything").unwrap(); assert_eq!( missing_in_pen(base, &files), vec![1, 2], "the partial and the absent are owed; the whole one and the \ unstated-size one are not" ); } /// A record laid out the way apply sees one: the user's folder, the /// audition pen beside the download root, and a library root to file /// into. Files carry distinct bytes so "which version landed" is a /// content question, not a guess. struct Bed { _tmp: tempfile::TempDir, src_root: PathBuf, dest_root: PathBuf, download_root: PathBuf, library: library::Library, rel: String, } fn bed(pen_tracks: usize) -> Bed { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let rel = "Weezer/Pinkerton".to_string(); let src = root.join("source").join(&rel); std::fs::create_dir_all(&src).unwrap(); for n in 1..=2 { std::fs::write(src.join(format!("{n:02} track.mp3")), format!("mine {n}")).unwrap(); } let download_root = root.join("downloads"); let pen = audition_dir(&download_root, &rel); std::fs::create_dir_all(&pen).unwrap(); for n in 1..=pen_tracks { std::fs::write(pen.join(format!("{n:02} track.flac")), format!("theirs {n}")).unwrap(); } let library = library::Library::open(&root.join("library.db")).unwrap(); Bed { src_root: root.join("source"), dest_root: root.join("library"), download_root, library, rel, _tmp: tmp, } } fn choices(audio: &str, want: Option) -> ApplyChoices { ApplyChoices { art_url: None, artist: None, album: None, year: None, label: None, genre: None, track_titles: None, audio: Some(audio.into()), audio_tracks: want, audio_files: None, } } fn album(b: &Bed, ch: Option) -> ApplyAlbum { ApplyAlbum { rel: b.rel.clone(), artist: "Weezer".into(), album: "Pinkerton".into(), year: Some(1996), choices: ch, parts: Vec::new(), } } fn run(b: &Bed, a: &ApplyAlbum) -> Result<(usize, u64, usize), String> { apply_one( &b.library, &b.src_root, &b.dest_root, &b.download_root, "{artist}/{album}", a, None, 1500, false, ) } fn landed(b: &Bed) -> Vec<(String, String)> { let dir = b.dest_root.join("Weezer").join("Pinkerton"); let mut out: Vec<_> = std::fs::read_dir(&dir) .unwrap() .flatten() .map(|e| { ( e.file_name().to_string_lossy().into_owned(), std::fs::read_to_string(e.path()).unwrap_or_default(), ) }) .collect(); out.sort(); out } /// A folded release is ONE album made of disc folders, and every folder /// numbers its tracks from 1. The disc has to survive the flattening or /// the album becomes unreadable: eleven volumes of Analord filed as one /// record put eleven "track 1"s at the top of the list in alphabetical /// order, with nothing anywhere to say which volume any of them was from. #[test] fn a_folded_release_keeps_its_discs() { let b = bed(0); for (part, names) in [("CD1", ["01 one.mp3", "02 two.mp3"]), ("CD2", ["01 three.mp3", "02 four.mp3"])] { let dir = b.src_root.join(&b.rel).join(part); std::fs::create_dir_all(&dir).unwrap(); for n in names { std::fs::write(dir.join(n), format!("{part} {n}")).unwrap(); } } let mut a = album(&b, None); a.parts = vec!["CD1".into(), "CD2".into()]; let (tracks, _, _) = run(&b, &a).unwrap(); assert_eq!(tracks, 4); let album_id = b .library .upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0) .unwrap(); let rows = b.library.album_tracks(album_id).unwrap(); assert_eq!( rows.iter().map(|t| t.disc_no).collect::>(), vec![Some(1), Some(1), Some(2), Some(2)], "disc-major order, both discs numbered in the order the fold joined them" ); // A single part is a container folder, not a two-disc set — numbering // it would put a disc heading on an ordinary album. let mut solo = album(&b, None); solo.parts = vec!["CD1".into()]; let b2 = bed(0); let dir = b2.src_root.join(&b2.rel).join("CD1"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("01 one.mp3"), "only").unwrap(); run(&b2, &solo).unwrap(); let solo_id = b2 .library .upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0) .unwrap(); assert!( b2.library.album_tracks(solo_id).unwrap().iter().all(|t| t.disc_no.is_none()), "one part is not a disc" ); } /// The pen path is DERIVED from the record, never passed in — and the /// record is a folder name off somebody's disk, so it can contain /// anything. `import_audition_clear` deletes this directory, so "it always /// lands inside the cache, and is always exactly one folder deep" is a /// property worth holding on to rather than assuming. #[test] fn a_records_pen_can_never_name_anything_outside_the_cache() { let root = Path::new("D:\\Music\\Library"); let pen = root.join("(Cache)").join("_audition"); for rel in [ "..\\..\\Windows\\System32", "../../../etc/passwd", "C:\\Windows", "Artist\\Album", ".", "", ] { let dir = audition_dir(root, rel); assert!(dir.starts_with(&pen), "{rel:?} escaped the pen: {dir:?}"); assert_eq!( dir.components().count(), pen.components().count() + 1, "{rel:?} is not exactly one folder inside the pen: {dir:?}" ); } } /// A record that changed peers mid-audition has TWO copies in one pen. The /// switch cancels the abandoned peer's transfers, but it cannot un-write /// the files they already delivered, and the pen is keyed by RECORD rather /// than by peer. Taking "every audio file in the folder" filed both: /// Centipede Hz landed as 17 tracks — deletia's 11 plus the 6 smash2 got /// through before it was dropped — each duplicate under its own peer's /// naming. The copy the REVIEW chose is the one that imports. #[test] fn the_dropped_peers_files_do_not_import_beside_the_copy_that_was_kept() { let b = bed(2); // the chosen copy: "01 track.flac", "02 track.flac" let pen = audition_dir(&b.download_root, &b.rel); // What the abandoned peer left behind: the same music, its own naming. for n in 1..=2 { std::fs::write( pen.join(format!("Weezer-Pinkerton-{n:02}-track-[FLAC 16bit].flac")), format!("dropped peer {n}"), ) .unwrap(); } let mut ch = choices("theirs", Some(2)); ch.audio_files = Some(vec![ "@peer\\Music\\Pinkerton\\01 track.flac".into(), "@peer\\Music\\Pinkerton\\02 track.flac".into(), ]); let (tracks, _, _) = run(&b, &album(&b, Some(ch))).unwrap(); assert_eq!(tracks, 2, "only the chosen copy's tracks are filed"); let names: Vec = landed(&b).into_iter().map(|(n, _)| n).collect(); assert!( names.iter().all(|n| !n.contains("[FLAC 16bit]")), "the dropped peer's files must not reach the library: {names:?}" ); assert_eq!( names.iter().filter(|n| n.ends_with(".flac")).count(), 2, "eleven-plus-six is what this test exists to prevent: {names:?}" ); } /// Re-importing the same record must not copy it again — even though the /// imported files no longer match their sources byte for byte. /// /// They never can: apply writes the review's tags and embeds the chosen /// cover into the copy, so every imported file is larger than the source /// it came from. The old rule asked "same name AND same size?" to decide a /// track was already filed, so after the first run the answer was always /// no, and each import laid down another " (n)" copy with its own library /// row. Three runs over one album left three of everything. #[test] fn re_importing_does_not_duplicate_a_track_apply_has_since_rewritten() { let b = bed(0); let a = album(&b, Some(choices("yours", None))); run(&b, &a).unwrap(); assert_eq!(landed(&b).len(), 2, "first import files the album"); // What tagging does: the copy grows. Nothing else about it changed. let dir = b.dest_root.join("Weezer").join("Pinkerton"); for n in 1..=2 { let f = dir.join(format!("{n:02} track.mp3")); let mut body = std::fs::read(&f).unwrap(); body.extend_from_slice(b"...embedded cover bytes..."); std::fs::write(&f, body).unwrap(); } let (tracks, _, _) = run(&b, &a).unwrap(); let after = landed(&b); assert_eq!(tracks, 2, "the record still reports its two tracks"); assert_eq!( after.len(), 2, "a second import must add no files, got {:?}", after.iter().map(|(n, _)| n).collect::>() ); assert!( !after.iter().any(|(n, _)| n.contains('(')), "no \" (n)\" duplicate was made: {:?}", after.iter().map(|(n, _)| n).collect::>() ); // …and the rewritten bytes are left alone, not clobbered by a re-copy. assert!( after.iter().all(|(_, body)| body.ends_with("...embedded cover bytes...")), "the imported file keeps the tags apply wrote into it" ); } /// A record whose artwork is a loose file rather than a tag still gets a /// cover. The review offers that picture as "yours", so accepting it has /// to file it — apply used to look only at embedded art, and imported a /// record it had just shown a cover for with no cover at all. #[test] fn a_folder_image_is_filed_when_nothing_is_embedded() { let b = bed(0); let src = b.src_root.join(&b.rel); // A real JPEG, because the cover writer decodes what it is given. let img = image::RgbImage::from_pixel(8, 8, image::Rgb([9, 9, 9])); let mut jpeg = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgb8(img) .write_with_encoder(image::codecs::jpeg::JpegEncoder::new(&mut jpeg)) .unwrap(); std::fs::write(src.join("Folder.jpg"), jpeg.into_inner()).unwrap(); // The tracks carry no tags at all, so there is nothing to extract. run(&b, &album(&b, None)).unwrap(); let dir = b.dest_root.join("Weezer").join("Pinkerton"); assert!(dir.join("cover.jpg").is_file(), "the folder image became the album cover"); assert!(dir.join("folder.jpg").is_file(), "and the second canonical name"); } /// Keeping the FOUND COPY replaces the audio, not the artwork. /// /// The review shows the picture from YOUR folder and calls it "yours"; a /// downloaded copy often carries none at all. Apply used to read art from /// whichever folder the audio came from, so choosing "theirs" imported the /// record with no cover while a 1500px one sat in the source files. #[test] fn keeping_theirs_still_takes_the_artwork_from_your_own_folder() { let b = bed(2); // The pen's copy has no picture anywhere. The user's folder does. let src = b.src_root.join(&b.rel); let img = image::RgbImage::from_pixel(8, 8, image::Rgb([7, 7, 7])); let mut jpeg = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgb8(img) .write_with_encoder(image::codecs::jpeg::JpegEncoder::new(&mut jpeg)) .unwrap(); let bytes = jpeg.into_inner(); std::fs::write(src.join("Folder.jpg"), &bytes).unwrap(); run(&b, &album(&b, Some(choices("theirs", Some(2))))).unwrap(); let dir = b.dest_root.join("Weezer").join("Pinkerton"); assert!( dir.join("cover.jpg").is_file(), "the record's own artwork is filed even though the audio came from the pen" ); // And the audio really is the found copy, not a silent fallback. let landed_names: Vec = landed(&b).into_iter().map(|(n, _)| n).collect(); assert!( landed_names.iter().any(|n| n.ends_with(".flac")), "the pen's tracks are what landed: {landed_names:?}" ); } /// Windows Media Player's derived thumbnails are a last resort, never a /// preference — a real picture beside them wins. #[test] fn a_real_folder_image_beats_a_media_player_thumbnail() { let b = bed(0); let src = b.src_root.join(&b.rel); let write = |name: &str, size: u32| { let img = image::RgbImage::from_pixel(size, size, image::Rgb([1, 2, 3])); let mut out = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgb8(img) .write_with_encoder(image::codecs::jpeg::JpegEncoder::new(&mut out)) .unwrap(); std::fs::write(src.join(name), out.into_inner()).unwrap(); }; write("AlbumArt_Large.jpg", 64); // WMP's, and the bigger of the two write("Folder.jpg", 16); let picked = super::folder_image_of(&src).unwrap(); assert_eq!( picked.file_name().unwrap(), "Folder.jpg", "a real image wins even when the derived one is larger" ); } /// The other half of the same rule: two DIFFERENT files that land on one /// name inside a single run are a collision, and both must survive. #[test] fn one_run_still_disambiguates_two_files_with_the_same_name() { let b = bed(0); // A folded release: parts that each number from 01. let part_a = b.src_root.join(&b.rel).join("Disc 1"); let part_b = b.src_root.join(&b.rel).join("Disc 2"); std::fs::create_dir_all(&part_a).unwrap(); std::fs::create_dir_all(&part_b).unwrap(); std::fs::write(part_a.join("01 track.mp3"), "disc one").unwrap(); std::fs::write(part_b.join("01 track.mp3"), "disc two").unwrap(); let mut a = album(&b, None); a.parts = vec!["Disc 1".into(), "Disc 2".into()]; let (tracks, _, _) = run(&b, &a).unwrap(); let after = landed(&b); assert_eq!(tracks, 2, "both discs' track 1 is imported"); let bodies: Vec<&str> = after.iter().map(|(_, body)| body.as_str()).collect(); assert!(bodies.contains(&"disc one") && bodies.contains(&"disc two"), "neither disc was dropped as a false reuse: {after:?}"); } #[test] fn keeping_theirs_moves_the_pen_and_leaves_the_source_alone() { let b = bed(2); let a = album(&b, Some(choices("theirs", Some(2)))); let (tracks, _, _) = run(&b, &a).unwrap(); assert_eq!(tracks, 2); let files = landed(&b); assert_eq!(files.len(), 2, "the found copy's tracks are what landed"); assert!(files.iter().all(|(n, c)| n.ends_with(".flac") && c.starts_with("theirs"))); // A move, not a copy: the pen is spent. assert!(!audition_dir(&b.download_root, &b.rel).exists()); // His folder is exactly as it was. assert_eq!( std::fs::read_dir(b.src_root.join(&b.rel)).unwrap().count(), 2 ); } #[test] fn re_applying_a_kept_copy_is_a_no_op_not_a_failure() { let b = bed(2); let a = album(&b, Some(choices("theirs", Some(2)))); run(&b, &a).unwrap(); // "Save & finish later" banks records as it goes; the final apply // passes every record again, by which time the pen is empty. let (tracks, _, _) = run(&b, &a).expect("the second pass must not fail"); assert_eq!(tracks, 2); assert_eq!(landed(&b).len(), 2, "no ' (2)' duplicates"); } #[test] fn a_half_arrived_pen_is_refused_rather_than_filed_short() { let b = bed(1); let a = album(&b, Some(choices("theirs", Some(12)))); let err = run(&b, &a).unwrap_err(); assert!(err.contains("1 of 12"), "{err}"); assert!(err.contains("Keep mine"), "the way out is in the reason: {err}"); // Refused means nothing was written — not even the folder. Creating it // up front left an empty album in the library on every attempt. assert!( !b.dest_root.join("Weezer").join("Pinkerton").exists(), "a refusal must not leave an empty album folder behind" ); } #[test] fn flipping_to_theirs_replaces_the_copy_it_supersedes() { let b = bed(2); // First pass banks the record as HIS copy — the mp3s land and register. run(&b, &album(&b, Some(choices("yours", None)))).unwrap(); assert_eq!(landed(&b).len(), 2); // Then the mind changes inside the retention window: rule 5 says that // flip is free, and "replace" has to mean the folder holds ONE version. let (tracks, _, replaced) = run(&b, &album(&b, Some(choices("theirs", Some(2))))).unwrap(); assert_eq!(tracks, 2); assert_eq!(replaced, 2, "both superseded files are reported, not silent"); let files = landed(&b); assert_eq!(files.len(), 2, "one version in the folder, not both"); assert!(files.iter().all(|(n, c)| n.ends_with(".flac") && c.starts_with("theirs"))); // And the rows follow the files. let album_id = b.library.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0).unwrap(); let rows = b.library.album_tracks(album_id).unwrap(); assert_eq!(rows.len(), 2); assert!(rows.iter().all(|t| t.path.ends_with(".flac"))); // His source folder is STILL untouched — that rule never bends. assert_eq!(std::fs::read_dir(b.src_root.join(&b.rel)).unwrap().count(), 2); } #[test] fn a_replacement_never_reaches_outside_the_album_folder() { let b = bed(2); // A file registered to the same album but living somewhere else (a // download filed before the library root moved, say) must survive. let elsewhere = b.dest_root.join("Elsewhere"); std::fs::create_dir_all(&elsewhere).unwrap(); let stray = elsewhere.join("stray.mp3"); std::fs::write(&stray, "stray").unwrap(); let album_id = b .library .upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0) .unwrap(); b.library .upsert_track(album_id, None, None, "Stray", "Weezer", None, None, None, None, Some(5), &stray.to_string_lossy(), 0) .unwrap(); let (_, _, replaced) = run(&b, &album(&b, Some(choices("theirs", Some(2))))).unwrap(); assert_eq!(replaced, 0, "nothing outside the album's own folder is touched"); assert!(stray.exists()); assert!(b.library.album_tracks(album_id).unwrap().iter().any(|t| t.path.ends_with("stray.mp3"))); } #[test] fn a_record_takes_its_own_folder_and_not_the_tree_below_it() { // The shape that broke it: an artist folder holding ONE stray track // plus the albums themselves. The scan files that as a 1-track record; // apply used to recurse and copy the whole artist in on top of it. let b = bed(2); let artist_dir = b.src_root.join("Animal Collective"); std::fs::create_dir_all(artist_dir.join("2003 - Campfire Songs")).unwrap(); std::fs::create_dir_all(artist_dir.join("2005 - Feels")).unwrap(); std::fs::write(artist_dir.join("01 Prester John.mp3"), "stray").unwrap(); for (d, n) in [("2003 - Campfire Songs", 4), ("2005 - Feels", 9)] { for i in 1..=n { std::fs::write(artist_dir.join(d).join(format!("{i:02} track.flac")), "x").unwrap(); } } let a = ApplyAlbum { rel: "Animal Collective".into(), artist: "Animal Collective".into(), album: "Prester John".into(), year: Some(2021), choices: None, parts: Vec::new(), }; let (tracks, _, _) = run(&b, &a).unwrap(); assert_eq!(tracks, 1, "the record owns ONE file; the 13 below it belong to other records"); let dest = b.dest_root.join("Animal Collective").join("Prester John"); let landed: Vec<_> = std::fs::read_dir(&dest) .unwrap() .flatten() .map(|e| e.file_name().to_string_lossy().into_owned()) .collect(); assert_eq!(landed, vec!["01 Prester John.mp3"], "got: {landed:?}"); } /// The other half of that rule. A release the scan met as its parts — /// Analord's eleven volumes, a two-disc set — is folded back into one /// record by the review, which hands apply an explicit part list. Apply /// must take those folders, IN THAT ORDER, and still take nothing it was /// not given: a sibling folder that is somebody else's record stays put. #[test] fn a_folded_release_takes_its_parts_in_order_and_no_sibling() { let b = bed(0); let artist_dir = b.src_root.join("AFX"); for (vol, n) in [("Analord 01", 3), ("Analord 02", 2)] { std::fs::create_dir_all(artist_dir.join("Analord Volume 1-11").join(vol)).unwrap(); for i in 1..=n { std::fs::write( artist_dir.join("Analord Volume 1-11").join(vol).join(format!("{i:02}.mp3")), format!("{vol} {i}"), ) .unwrap(); } } // Not part of the fold, and not named in `parts`: it must be untouched. std::fs::create_dir_all(artist_dir.join("Hangable Auto Bulb")).unwrap(); std::fs::write(artist_dir.join("Hangable Auto Bulb").join("01.mp3"), "other").unwrap(); let a = ApplyAlbum { rel: "AFX/Analord Volume 1-11".into(), artist: "AFX".into(), album: "Analord Volume 1-11".into(), year: Some(2005), choices: None, parts: vec!["Analord 01".into(), "Analord 02".into()], }; let (tracks, _, _) = run(&b, &a).unwrap(); assert_eq!(tracks, 5, "three from volume 1 and two from volume 2"); let dest = b.dest_root.join("AFX").join("Analord Volume 1-11"); let mut landed: Vec<_> = std::fs::read_dir(&dest) .unwrap() .flatten() .map(|e| std::fs::read_to_string(e.path()).unwrap()) .collect(); landed.sort(); assert_eq!( landed, vec!["Analord 01 1", "Analord 01 2", "Analord 01 3", "Analord 02 1", "Analord 02 2"], "every part's audio, and only the parts named", ); assert!( !b.dest_root.join("AFX").join("Hangable Auto Bulb").exists(), "a sibling folder is another record — the fold must not reach it", ); } #[test] fn keeping_yours_still_copies_the_source() { let b = bed(2); let a = album(&b, Some(choices("yours", None))); let (tracks, _, _) = run(&b, &a).unwrap(); assert_eq!(tracks, 2); let files = landed(&b); assert!(files.iter().all(|(n, c)| n.ends_with(".mp3") && c.starts_with("mine"))); // Unclaimed: the pen keeps the version he passed over, so a // mind-change inside the retention window is a free flip. assert!(audition_dir(&b.download_root, &b.rel).is_dir()); assert_eq!(std::fs::read_dir(b.src_root.join(&b.rel)).unwrap().count(), 2); } }