diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 156f02f27..9513e261e 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -908,6 +908,12 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + [[package]] name = "built" version = "0.8.1" @@ -1564,6 +1570,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -2476,6 +2497,24 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flacenc" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c892c2b5fa08f967e8b5ad29121570b4762202f2402033ce08479ec65eccd0" +dependencies = [ + "built 0.7.7", + "crc", + "crossbeam-channel", + "heapless", + "log", + "md-5", + "num-traits", + "rustversion", + "seq-macro", + "serde", +] + [[package]] name = "flate2" version = "1.1.9" @@ -3061,6 +3100,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -3110,6 +3158,17 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "serde", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -6699,6 +6758,7 @@ dependencies = [ "qbz-playlist-import", "qbz-plex", "qbz-qobuz", + "qbz-rip", "qbz-source", "qbz-subsonic", "qbz-text-utils", @@ -6742,6 +6802,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "qbz-rip" +version = "2.0.2" +dependencies = [ + "flacenc", + "lofty", + "log", + "qbz-disc", + "thiserror 2.0.18", +] + [[package]] name = "qbz-secrets" version = "2.0.2" @@ -7196,7 +7267,7 @@ dependencies = [ "av-scenechange", "av1-grain", "bitstream-io", - "built", + "built 0.8.1", "cfg-if", "interpolate_name", "itertools 0.14.0", @@ -7839,6 +7910,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 351b69eba..d6d40222c 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -20,6 +20,7 @@ members = [ # dependency of qbz-dsd's reverse: qbz-dsd will depend on THIS, never the # other way, or the two close a cycle. "qbz-disc", + "qbz-rip", "qbz-secrets", "qbz-offline-cache", "qbz-plex", diff --git a/crates/qbz-rip/Cargo.toml b/crates/qbz-rip/Cargo.toml new file mode 100644 index 000000000..a45229477 --- /dev/null +++ b/crates/qbz-rip/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "qbz-rip" +version.workspace = true +edition.workspace = true + +[dependencies] +log = { workspace = true } +thiserror = { workspace = true } +qbz-disc = { path = "../qbz-disc" } +# Pure Rust, so it works on macOS with no native dependency and nothing extra +# to bundle into Flatpak, Snap or AppImage. +flacenc = "0.5" +# Already in the tree; writes FLAC Vorbis comments. +lofty = "0.23" diff --git a/crates/qbz-rip/examples/rip_one.rs b/crates/qbz-rip/examples/rip_one.rs new file mode 100644 index 000000000..e4fc03092 --- /dev/null +++ b/crates/qbz-rip/examples/rip_one.rs @@ -0,0 +1,52 @@ +//! Rip ONE track off the disc in the drive, tagged, into a directory. +//! `cargo run -p qbz-rip --example rip_one -- ` +fn main() { + let mut a = std::env::args().skip(1); + let want: u32 = a.next().expect("track number").parse().unwrap(); + let dest = std::path::PathBuf::from(a.next().expect("destination")); + + let dev = qbz_disc::list_devices().into_iter().next().expect("a drive"); + let toc = qbz_disc::read_toc(&dev).expect("toc"); + let starts: Vec = toc.audio_tracks().map(|t| t.start_lsn).collect(); + let id = qbz_disc::discid::disc_id(&starts, toc.leadout_lsn).expect("disc id"); + println!("disc id {id}"); + + let t = toc + .audio_tracks() + .find(|t| t.number as u32 == want) + .expect("that track"); + println!("track {} — {} sectors, {}s", t.number, t.sectors, t.duration_secs()); + + let plan = qbz_rip::RipPlan { + destination: dest, + album: "Fear Inoculum".into(), + album_artist: "Tool".into(), + year: Some(2019), + tracks: vec![qbz_rip::RipTrack { + number: t.number as u32, + title: "Chocolate Chip Trip".into(), + artist: "Tool".into(), + source: qbz_rip::RipSource::Cd { + device: dev.clone(), + start_lsn: t.start_lsn, + sectors: t.sectors, + }, + }], + }; + let started = std::time::Instant::now(); + let mut last = -1i32; + let out = qbz_rip::rip(&plan, |p| { + let pct = (p.fraction * 100.0) as i32; + if pct / 10 != last / 10 { + println!(" {pct}%"); + last = pct; + } + true + }) + .expect("rip"); + println!("wrote {:?} in {:.1}s", out, started.elapsed().as_secs_f32()); + for p in &out { + let md = std::fs::metadata(p).unwrap(); + println!(" {} bytes", md.len()); + } +} diff --git a/crates/qbz-rip/src/lib.rs b/crates/qbz-rip/src/lib.rs new file mode 100644 index 000000000..fd5a5e7a8 --- /dev/null +++ b/crates/qbz-rip/src/lib.rs @@ -0,0 +1,535 @@ +//! "Rip this disc into my library": CD audio in, tagged FLAC out. +//! +//! Frontend-agnostic (ADR-006). It knows about discs, samples and files; it +//! knows nothing about windows, and it reports progress through a callback so +//! a UI can drive it without this crate depending on one. +//! +//! PORTABILITY, which shaped the design: reading a CD is the only part that +//! differs between platforms. Linux talks to the drive through ioctls; macOS +//! MOUNTS an audio CD and presents each track as an AIFF file under +//! /Volumes, so there the "read" is a file read. [`RipSource`] is that seam, +//! and everything after it — encoding, tagging, naming — is shared. +//! +//! Encoding is `flacenc`, pure Rust: no libFLAC to build, nothing extra to +//! bundle into Flatpak, Snap or AppImage, and macOS gets it for free. + +use std::path::{Path, PathBuf}; + +/// Where one track's audio comes from. +pub enum RipSource { + /// Sectors off a drive (Linux). + Cd { + device: PathBuf, + start_lsn: u32, + sectors: u32, + }, + /// A file that already holds the audio — the macOS mounted-CD case, and + /// the seam a future "rip this folder" would reuse. The decoding is the + /// caller's, so this crate stays free of an audio-decoder dependency. + Samples { pcm: Vec }, +} + +/// One track to write. +pub struct RipTrack { + pub number: u32, + pub title: String, + pub artist: String, + pub source: RipSource, +} + +/// A whole job. +pub struct RipPlan { + /// Directory the ALBUM folder is created inside — the destination the + /// user picked, never guessed. + pub destination: PathBuf, + pub album: String, + pub album_artist: String, + pub year: Option, + pub tracks: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum RipError { + #[error("the destination {0} is not a writable directory")] + Destination(PathBuf), + #[error("reading the disc failed on track {track}: {source}")] + Read { + track: u32, + #[source] + source: qbz_disc::CdError, + }, + #[error("encoding track {track} failed: {why}")] + Encode { track: u32, why: String }, + #[error("writing {path} failed: {source}")] + Write { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("cancelled")] + Cancelled, +} + +/// Progress, reported per track and per chunk within it. +#[derive(Debug, Clone, Copy)] +pub struct Progress { + pub track_index: usize, + pub track_count: usize, + /// 0.0 to 1.0 within the current track. + pub fraction: f32, +} + +/// A filename that survives every filesystem the app runs on. +/// +/// The separators are the obvious part. The rest is the part that bites: +/// Windows refuses a handful of characters and a set of reserved DEVICE names, +/// macOS stores `:` as a path separator in some APIs, and a trailing dot or +/// space is silently dropped by Windows — turning "Track 1." into "Track 1" +/// and two tracks into a collision. A ripped library is likely to be copied +/// between machines, so this sanitises for the strictest of them. +pub fn safe_filename(raw: &str) -> String { + const RESERVED: [&str; 22] = [ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", + "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ]; + let mut s: String = raw + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + s = s.trim().trim_end_matches('.').trim().to_string(); + if s.is_empty() { + s = "untitled".to_string(); + } + let stem = s.split('.').next().unwrap_or(&s).to_ascii_uppercase(); + if RESERVED.contains(&stem.as_str()) { + s.insert(0, '_'); + } + // Long titles exist (classical especially); leave room for a number + // prefix and an extension inside the usual 255-byte limit. + if s.len() > 180 { + s.truncate(180); + s = s.trim().to_string(); + } + s +} + +/// Decode raw CD-DA sectors into the interleaved i32 samples the encoder +/// wants. +/// +/// The drive hands back little-endian 16-bit stereo — MEASURED, not assumed +/// (`qbz_disc::cdda` documents the 285x margin that settled it). Widening to +/// i32 is what `flacenc` takes; the sample VALUES are untouched, so the FLAC +/// is a bit-exact copy of what came off the disc. +pub fn cdda_to_samples(raw: &[u8], out: &mut Vec) { + out.reserve(raw.len() / 2); + for pair in raw.chunks_exact(2) { + out.push(i16::from_le_bytes([pair[0], pair[1]]) as i32); + } +} + +/// Encode interleaved 16-bit samples as FLAC bytes. +pub fn encode_flac(samples: &[i32], channels: usize, rate: usize) -> Result, String> { + use flacenc::component::BitRepr; + use flacenc::error::Verify; + + let config = flacenc::config::Encoder::default() + .into_verified() + .map_err(|e| format!("encoder config: {e:?}"))?; + let source = flacenc::source::MemSource::from_samples(samples, channels, 16, rate); + let stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size) + .map_err(|e| format!("encode: {e:?}"))?; + let mut sink = flacenc::bitsink::ByteSink::new(); + stream + .write(&mut sink) + .map_err(|e| format!("serialise: {e:?}"))?; + let mut bytes = sink.as_slice().to_vec(); + declare_fixed_blocksize(&mut bytes); + Ok(bytes) +} + +/// Make STREAMINFO declare a FIXED block size, which is what the frames say. +/// +/// This is not cosmetic — without it the file cannot be SEEKED. +/// +/// A stream's last frame is normally short: 12 709 620 samples at a 4096 +/// block leaves 3828 for the final one. `flacenc` reports that honestly as +/// `min_blocksize = 3828, max_blocksize = 4096` — but in FLAC, `min != max` +/// MEANS variable-blocksize, and a decoder reading a variable-blocksize +/// stream expects each frame header to carry a SAMPLE number. The frames +/// carry FRAME numbers, correctly, because the stream really is fixed. So +/// libFLAC warns once per frame ("sample or frame number does not increase +/// correctly … file might not be seekable") and every seek fails with +/// `FLAC__STREAM_DECODER_SEEK_ERROR`. Measured on a real rip: 3102 warnings +/// over 3103 frames, and `flac -d --skip=2:00` refused outright. +/// +/// libFLAC's own encoder writes `min == max` for exactly this reason; a short +/// final frame is expected and does not make a stream variable. Two bytes. +/// +/// Layout: "fLaC" (4) + block header (4) + STREAMINFO, whose first field is +/// min_blocksize (u16 BE) and second is max_blocksize. +fn declare_fixed_blocksize(bytes: &mut [u8]) { + const MIN_AT: usize = 8; + const MAX_AT: usize = 10; + if bytes.len() < MAX_AT + 2 || &bytes[0..4] != b"fLaC" { + return; + } + // Block type 0 = STREAMINFO. If the first block is anything else the file + // is not shaped the way this assumes, and doing nothing is right. + if bytes[4] & 0x7F != 0 { + return; + } + let (a, b) = (bytes[MAX_AT], bytes[MAX_AT + 1]); + bytes[MIN_AT] = a; + bytes[MIN_AT + 1] = b; +} + +/// Write Vorbis comments onto a finished FLAC file. +pub fn tag_flac( + path: &Path, + album: &str, + album_artist: &str, + artist: &str, + title: &str, + track: u32, + total: u32, + year: Option, +) -> Result<(), String> { + use lofty::config::WriteOptions; + use lofty::file::TaggedFileExt; + use lofty::prelude::{Accessor, ItemKey, TagExt}; + use lofty::probe::Probe; + use lofty::tag::{Tag, TagType}; + + let mut tagged = Probe::open(path) + .map_err(|e| format!("open for tagging: {e}"))? + .read() + .map_err(|e| format!("read for tagging: {e}"))?; + let tag = match tagged.primary_tag_mut() { + Some(t) => t, + None => { + tagged.insert_tag(Tag::new(TagType::VorbisComments)); + tagged + .primary_tag_mut() + .ok_or_else(|| "no tag after insert".to_string())? + } + }; + tag.set_title(title.to_string()); + tag.set_artist(artist.to_string()); + tag.set_album(album.to_string()); + tag.set_track(track); + tag.set_track_total(total); + if let Some(y) = year { + // `set_year` is not on lofty's Accessor for every tag type; the + // Vorbis comment a FLAC actually carries is DATE, so write that. + tag.insert_text(ItemKey::RecordingDate, y.to_string()); + } + // ALBUMARTIST is what a library groups a compilation by; without it a + // disc with per-track artists scatters into one album per track. + tag.insert_text(ItemKey::AlbumArtist, album_artist.to_string()); + tag.save_to_path(path, WriteOptions::default()) + .map_err(|e| format!("write tags: {e}")) +} + +/// Run a rip. +/// +/// `progress` is called often enough to move a bar and is where a UI also +/// decides to STOP: returning `false` cancels, and the partial file of the +/// track in flight is deleted. A half-written FLAC left in a music folder is +/// worse than no file — it scans, it plays, and it ends early. +/// +/// Files land in `destination/ - /NN - .flac`. +/// The folder is created; an existing file is overwritten only after the new +/// one is fully encoded, so a failure never destroys a previous rip. +pub fn rip<F>(plan: &RipPlan, mut progress: F) -> Result<Vec<PathBuf>, RipError> +where + F: FnMut(Progress) -> bool, +{ + if !plan.destination.is_dir() { + return Err(RipError::Destination(plan.destination.clone())); + } + let folder = plan.destination.join(safe_filename(&format!( + "{} - {}", + plan.album_artist, plan.album + ))); + std::fs::create_dir_all(&folder).map_err(|e| RipError::Write { + path: folder.clone(), + source: e, + })?; + + let total = plan.tracks.len(); + let mut written = Vec::with_capacity(total); + + for (i, track) in plan.tracks.iter().enumerate() { + let name = format!( + "{:02} - {}.flac", + track.number, + safe_filename(&track.title) + ); + let path = folder.join(&name); + + // Read the whole track before encoding. A CD track is at most ~170 MB + // of PCM and the encoder wants the samples anyway; streaming it would + // buy nothing but complexity here. + let mut samples: Vec<i32> = Vec::new(); + match &track.source { + RipSource::Samples { pcm } => samples.extend_from_slice(pcm), + RipSource::Cd { + device, + start_lsn, + sectors, + } => { + let toc_track = qbz_disc::TocTrack { + number: track.number as u8, + start_lsn: *start_lsn, + sectors: *sectors, + is_audio: true, + }; + let mut reader = qbz_disc::cdda::TrackReader::open(device, &toc_track) + .map_err(|e| RipError::Read { + track: track.number, + source: e, + })?; + let mut raw = Vec::new(); + samples.reserve(*sectors as usize * qbz_disc::CDDA_SECTOR_BYTES / 2); + loop { + let n = reader.next_chunk(&mut raw).map_err(|e| RipError::Read { + track: track.number, + source: e, + })?; + if n == 0 { + break; + } + cdda_to_samples(&raw, &mut samples); + let done = samples.len() as f32 + / (*sectors as usize * qbz_disc::CDDA_SECTOR_BYTES / 2).max(1) as f32; + // Reading is most of the wall clock, so the bar tracks it + // and the encode is the last slice. + if !progress(Progress { + track_index: i, + track_count: total, + fraction: (done * 0.9).min(0.9), + }) { + return Err(RipError::Cancelled); + } + } + } + } + + let flac = encode_flac(&samples, 2, 44_100).map_err(|why| RipError::Encode { + track: track.number, + why, + })?; + // Write to a temporary name and rename: an interrupted write must not + // leave a truncated .flac behind that looks like a finished track. + let tmp = path.with_extension("flac.part"); + std::fs::write(&tmp, &flac).map_err(|e| RipError::Write { + path: tmp.clone(), + source: e, + })?; + std::fs::rename(&tmp, &path).map_err(|e| RipError::Write { + path: path.clone(), + source: e, + })?; + // Tag AFTER the rename, never before. `lofty` identifies a file by its + // EXTENSION, and `.flac.part` is not one it knows — tagging the + // temporary file silently wrote nothing, and the first rip came out + // with a STREAMINFO block and no Vorbis comments at all. + if let Err(why) = tag_flac( + &path, + &plan.album, + &plan.album_artist, + &track.artist, + &track.title, + track.number, + total as u32, + plan.year, + ) { + // A missing tag is a blemish; a missing FILE is a failed rip. + log::warn!("[rip] track {} tags not written: {why}", track.number); + } + + log::info!( + "[rip] {} -> {} ({} samples, {} bytes)", + track.number, + path.display(), + samples.len(), + flac.len() + ); + written.push(path); + + if !progress(Progress { + track_index: i, + track_count: total, + fraction: 1.0, + }) { + return Err(RipError::Cancelled); + } + } + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_filename_survives_every_filesystem_it_might_land_on() { + assert_eq!(safe_filename("AC/DC: Back in Black?"), "AC_DC_ Back in Black_"); + // Windows drops a trailing dot silently, which would collide two + // tracks whose titles differ only by it. + assert_eq!(safe_filename("Intro."), "Intro"); + assert_eq!(safe_filename(" "), "untitled"); + // A reserved DEVICE name is not a filename on Windows even with an + // extension: CON.flac fails. + assert_eq!(safe_filename("CON"), "_CON"); + assert_eq!(safe_filename("con.flac"), "_con.flac"); + assert_eq!(safe_filename("Concerto"), "Concerto"); + } + + #[test] + fn a_long_title_is_trimmed_with_room_for_the_rest_of_the_name() { + let long = "a".repeat(300); + let out = safe_filename(&long); + assert!(out.len() <= 180); + // "07 - " plus ".flac" still has to fit a 255-byte name. + assert!(out.len() + 5 + 5 < 255); + } + + #[test] + fn cd_bytes_widen_to_samples_without_changing_a_value() { + // Little-endian, measured on the owner's drive. If this ever reads as + // big-endian the FLAC is noise, so the values are pinned. + let raw = [0x00, 0x80, 0xFF, 0x7F, 0x01, 0x00]; + let mut out = Vec::new(); + cdda_to_samples(&raw, &mut out); + assert_eq!(out, vec![i16::MIN as i32, i16::MAX as i32, 1]); + } + + #[test] + fn streaminfo_declares_a_fixed_block_size_so_the_file_can_be_seeked() { + // Deliberately NOT a multiple of the block size, which is the normal + // case and the one that broke: the last frame is short, flacenc + // reports min != max, and every decoder then treats the stream as + // variable-blocksize and refuses to seek. + let samples = vec![0i32; 44_100 * 2 + 1234]; + let flac = encode_flac(&samples, 2, 44_100).expect("encode"); + let min = u16::from_be_bytes([flac[8], flac[9]]); + let max = u16::from_be_bytes([flac[10], flac[11]]); + assert_eq!(min, max, "min != max makes the stream variable-blocksize"); + assert!(max > 0); + } + + #[test] + fn the_patch_leaves_anything_that_is_not_a_flac_alone() { + let mut junk = *b"NOTAFLACnnnnnnnn"; + let before = junk; + declare_fixed_blocksize(&mut junk); + assert_eq!(junk, before); + } + + #[test] + fn a_second_of_silence_round_trips_through_the_encoder() { + // Not a quality test — a wiring test. It proves the encoder is + // reachable, produces a real FLAC stream, and agrees about the shape + // of the samples it is handed. + let samples = vec![0i32; 44_100 * 2]; + let flac = encode_flac(&samples, 2, 44_100).expect("encode a second of silence"); + assert_eq!(&flac[0..4], b"fLaC", "must start with the FLAC magic"); + // Silence compresses hard; anything near the PCM size means the + // encoder did not actually run. + assert!( + flac.len() < samples.len() * 2 / 10, + "silence compressed to {} bytes", + flac.len() + ); + } + + #[test] + fn a_plan_with_samples_writes_tagged_files_in_a_named_folder() { + // End to end without a drive: two short tracks straight to disk. + let dir = std::env::temp_dir().join(format!("qbz-rip-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let plan = RipPlan { + destination: dir.clone(), + album: "Fear Inoculum".into(), + album_artist: "Tool".into(), + year: Some(2019), + tracks: vec![ + RipTrack { + number: 1, + title: "Fear Inoculum".into(), + artist: "Tool".into(), + source: RipSource::Samples { pcm: vec![0i32; 8820] }, + }, + RipTrack { + number: 7, + title: "7empest".into(), + artist: "Tool".into(), + source: RipSource::Samples { pcm: vec![0i32; 8820] }, + }, + ], + }; + let out = rip(&plan, |_| true).expect("rip"); + assert_eq!(out.len(), 2); + assert!(out[0].ends_with("Tool - Fear Inoculum/01 - Fear Inoculum.flac")); + assert!(out[1].ends_with("Tool - Fear Inoculum/07 - 7empest.flac")); + for p in &out { + let b = std::fs::read(p).unwrap(); + assert_eq!(&b[0..4], b"fLaC"); + } + // The tags must actually BE there. The first version of this tagged + // the `.part` file, lofty ignored it for want of a known extension, + // and every ripped track came out with no Vorbis comments — a failure + // that only shows up if something looks. + let raw = std::fs::read(&out[0]).unwrap(); + let head = String::from_utf8_lossy(&raw[..raw.len().min(8192)]); + assert!(head.contains("Fear Inoculum"), "ALBUM tag missing from the file"); + assert!(head.contains("Tool"), "ARTIST tag missing from the file"); + + // No .part files may survive a successful rip. + let leftovers: Vec<_> = std::fs::read_dir(out[0].parent().unwrap()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".part")) + .collect(); + assert!(leftovers.is_empty(), "a partial file survived"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn cancelling_stops_and_leaves_no_partial_track() { + let dir = std::env::temp_dir().join(format!("qbz-rip-cancel-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let plan = RipPlan { + destination: dir.clone(), + album: "A".into(), + album_artist: "B".into(), + year: None, + tracks: vec![RipTrack { + number: 1, + title: "T".into(), + artist: "B".into(), + source: RipSource::Samples { pcm: vec![0i32; 8820] }, + }], + }; + let err = rip(&plan, |_| false).unwrap_err(); + assert!(matches!(err, RipError::Cancelled)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_destination_that_is_not_a_directory_is_refused_up_front() { + let plan = RipPlan { + destination: PathBuf::from("/definitely/not/here"), + album: "A".into(), + album_artist: "B".into(), + year: None, + tracks: vec![], + }; + assert!(matches!(rip(&plan, |_| true), Err(RipError::Destination(_)))); + } +}