playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+499 -33
View File
@@ -20,7 +20,7 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
use tauri::{Emitter, State};
use crate::config;
use crate::pipeline::{self, LibraryState};
@@ -368,9 +368,82 @@ pub fn import_session_clear(app: tauri::AppHandle) -> Result<(), 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 dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_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"))
}
@@ -394,20 +467,19 @@ pub struct ImportDest {
#[tauri::command]
pub fn import_get_dest(app: tauri::AppHandle) -> Result<ImportDest, String> {
let cfg = config::load(&app);
let suggested = if cfg.library_dir.is_none() {
Some(config::download_dir(&app, &cfg).to_string_lossy().into_owned())
} else {
None
};
let free_bytes = cfg
// `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()
.or(suggested.as_deref())
.and_then(|d| free_space_of(Path::new(d)));
.map_or_else(|| Some(dir.to_string_lossy().into_owned()), |_| None);
Ok(ImportDest {
dir: cfg.library_dir.clone(),
free_bytes: free_space_of(&dir),
dir: Some(dir.to_string_lossy().into_owned()),
suggested,
free_bytes,
layout: config::library_layout(&cfg).to_string(),
})
}
@@ -484,13 +556,62 @@ pub async fn import_local_art(
app: tauri::AppHandle,
source_root: String,
rel: String,
parts: Option<Vec<String>>,
) -> Result<Option<LocalArt>, String> {
tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel))
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())
}
fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option<LocalArt> {
/// 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;
@@ -499,14 +620,22 @@ fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option<
// 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 mut files: Vec<PathBuf> = Vec::new();
pipeline::collect_audio_files(&dir, &mut files, 0);
files.sort();
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());
let cache_dir = app.path().app_data_dir().ok()?.join("import-art");
// 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;
@@ -673,6 +802,92 @@ pub(crate) fn audition_dir(download_root: &Path, rel: &str) -> PathBuf {
.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).
@@ -701,8 +916,7 @@ pub async fn import_audition(
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 leaf = f.path.rsplit('\\').next().unwrap_or(&f.path);
let target = base.join(pipeline::safe_component(leaf));
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
@@ -755,13 +969,67 @@ pub async fn import_probe_file(path: String) -> Result<ProbeResult, String> {
/// 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> {
Ok(state.metadata.identify_album(&artist, &title).await)
// 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
@@ -835,6 +1103,15 @@ pub struct ApplyChoices {
/// 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)]
@@ -880,9 +1157,9 @@ pub async fn import_apply(
albums: Vec<ApplyAlbum>,
) -> Result<ApplyReport, String> {
let cfg = config::load(&app);
let Some(dest_root) = cfg.library_dir.clone().map(PathBuf::from) else {
return Err("no library folder chosen yet".into());
};
// 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);
@@ -1023,16 +1300,43 @@ fn apply_one(
// 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 part in &a.parts {
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
@@ -1217,16 +1521,24 @@ fn apply_one(
// 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.
if let Some(ch) = &a.choices {
// 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.artist.clone(),
artist: ch.and_then(|c| c.artist.clone()),
album_artist: None,
album: ch.album.clone(),
album: ch.and_then(|c| c.album.clone()),
title: track_titles.and_then(|t| t.get(i).cloned().flatten()),
track_no: None,
year: ch.year,
label: ch.label.clone(),
genre: ch.genre.clone(),
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(
@@ -1249,6 +1561,9 @@ fn apply_one(
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,
@@ -1328,6 +1643,39 @@ fn apply_one(
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
@@ -1378,6 +1726,7 @@ mod tests {
track_titles: None,
audio: Some(audio.into()),
audio_tracks: want,
audio_files: None,
}
}
@@ -1422,6 +1771,123 @@ mod tests {
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.
///
@@ -1659,7 +2125,7 @@ mod tests {
.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0)
.unwrap();
b.library
.upsert_track(album_id, None, "Stray", "Weezer", None, None, None, None, Some(5),
.upsert_track(album_id, None, None, "Stray", "Weezer", None, None, None, None, Some(5),
&stray.to_string_lossy(), 0)
.unwrap();