2242 lines
91 KiB
Rust
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);
|
|
}
|
|
}
|