Files
SoulStream/src-tauri/src/import.rs
T

2242 lines
91 KiB
Rust

//! 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<u32>,
pub bitrate: Option<u32>,
pub sample_rate: Option<u32>,
pub bit_depth: Option<u32>,
pub embedded_art: bool,
// What the file SAYS, for the review's "yours" column.
pub artist: Option<String>,
pub album: Option<String>,
pub title: Option<String>,
pub track_no: Option<u32>,
pub year: Option<u32>,
}
#[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<u32>,
/// Image files sitting loose in the folder.
pub folder_art: Vec<String>,
pub tracks: Vec<ScanTrack>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanResult {
pub root: String,
pub albums: Vec<ScanAlbum>,
}
/// 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<u32>,
file_kbps: Option<u32>,
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<u32>,
}
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<u32> {
// (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::<u32>().ok().filter(|y| (1900..=2100).contains(y))
} else {
None
}
})
.collect::<Vec<_>>();
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<ScanAlbum>, bool) {
let mut files: Vec<PathBuf> = Vec::new();
pipeline::collect_audio_files(root, &mut files, 0);
// Group by parent dir, preserving discovery order.
let mut order: Vec<PathBuf> = Vec::new();
let mut by_dir: std::collections::HashMap<PathBuf, Vec<PathBuf>> =
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<String> = 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<String> = 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<u32> = tracks.iter().filter_map(|t| t.bitrate).collect();
let avg_kbps =
(!rates.is_empty()).then(|| rates.iter().sum::<u32>() / 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<ScanResult, String> {
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<std::time::Instant> = 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<Option<String>, 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<String> {
let cfg = config::load(&app);
relative_to(&config::download_dir(&app, &cfg), Path::new(&root))
}
fn relative_to(from: &Path, to: &Path) -> Option<String> {
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<String>,
rel: Option<String>,
) -> Option<String> {
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<PathBuf, String> {
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<String>,
/// 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<String>,
pub free_bytes: Option<u64>,
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<ImportDest, String> {
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<String>,
) -> Result<ImportDest, String> {
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<u64> {
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<Vec<String>>,
) -> Result<Option<LocalArt>, 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<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut here: Vec<PathBuf> = 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<PathBuf> {
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<LocalArt> {
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 <img>).
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<PathBuf> = Vec::new();
let mut derived: Vec<PathBuf> = 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<Vec<u8>> {
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<PathBuf> {
let mut real: Vec<PathBuf> = Vec::new();
let mut derived: Vec<PathBuf> = 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::collections::HashMap<u64, (String, usize)>>,
> = 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<AuditionFile>,
) -> Result<Vec<usize>, 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<u64, String> {
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<usize> {
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\<rel>\` 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<AuditionFile>,
start_index: Option<usize>,
) -> Result<Vec<u64>, 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<u32>,
pub bitrate: Option<u32>,
pub sample_rate: Option<u32>,
pub bit_depth: Option<u32>,
pub bytes: u64,
pub title: Option<String>,
}
#[tauri::command]
pub async fn import_probe_file(path: String) -> Result<ProbeResult, String> {
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<String>,
tracks: Option<usize>,
track_titles: Option<Vec<String>>,
year: Option<u32>,
source_root: Option<String>,
rel: Option<String>,
parts: Option<Vec<String>>,
) -> Result<metadata::IdentifyResult, String> {
// 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<u64> {
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<metadata::CoverReport, String> {
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<u32>,
/// 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<ApplyChoices>,
/// 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<String>,
}
/// 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<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<u32>,
pub label: Option<String>,
pub genre: Option<String>,
/// 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<Vec<Option<String>>>,
/// 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<String>,
/// 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<usize>,
/// 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<Vec<String>>,
}
#[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<SkippedAlbum>,
/// 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<ApplyAlbum>,
) -> Result<ApplyReport, String> {
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<String, Vec<u8>> =
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<PathBuf> {
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<Option<u32>> = Vec::new();
let mut files: Vec<PathBuf> = 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<PathBuf> =
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<String> = None;
let mut art_sources: Vec<Vec<u8>> = 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<Vec<u8>> =
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<String> = 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<usize>) -> 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<ApplyChoices>) -> 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<_>>(),
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<String> = 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::<Vec<_>>()
);
assert!(
!after.iter().any(|(n, _)| n.contains('(')),
"no \" (n)\" duplicate was made: {:?}",
after.iter().map(|(n, _)| n).collect::<Vec<_>>()
);
// …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<String> = 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);
}
}