feat(rip): rip a CD to tagged FLAC, bit-exact and seekable

New crate `qbz-rip`: CD audio in, tagged FLAC out. Frontend-agnostic — it
knows discs, samples and files, and reports progress through a callback so a
UI can drive it without this crate depending on one.

`flacenc`, pure Rust: no libFLAC to build, nothing extra to bundle into
Flatpak, Snap or AppImage, and macOS gets it for free. `lofty` (already in the
tree) writes the Vorbis comments.

`RipSource` is the portability seam, and it is the only part that differs
between platforms: Linux reads sectors through ioctls, while macOS MOUNTS an
audio CD and presents each track as an AIFF under /Volumes — so there a "read"
is a file read. Everything after the seam is shared.

Two defects were found by ripping a real disc and looking at the result, and
neither would have failed a test:

  1. The tags were never written. Tagging happened on the `.flac.part` file,
     and lofty identifies a file by its EXTENSION — so it wrote nothing, and
     said nothing. Tagging now happens after the rename, with a test that
     reads the bytes back and asserts the strings are in them.

  2. The files could not be SEEKED. `flac -d --skip=2:00` returned
     FLAC__STREAM_DECODER_SEEK_ERROR and `flac -t` warned 3102 times over 3103
     frames. The cause is precise: a stream's last frame is short (12 709 620
     samples at a 4096 block leaves 3828), flacenc reports that honestly as
     min_blocksize 3828 / max 4096 — but in FLAC `min != max` MEANS
     variable-blocksize, and a decoder reading one expects SAMPLE numbers in
     the frame headers while the frames correctly carry FRAME numbers.
     libFLAC's own encoder writes min == max for exactly this reason. Two
     bytes of STREAMINFO, and now: zero warnings, seek works.

Verified against the drive, not inferred: 176 400 bytes decoded out of the
FLAC are byte-identical to the same sectors read straight off the disc. The
whole album ripped from the UI — seven tracks, 436 MB, every one decoding
clean, tagged with TITLE/ARTIST/ALBUM/TRACKNUMBER/TRACKTOTAL/DATE/ALBUMARTIST,
and no partial file left behind.

Filenames are sanitised for the strictest filesystem the library might be
copied to, including Windows' reserved DEVICE names and its habit of silently
dropping a trailing dot — which would turn two tracks into one collision.

cc
This commit is contained in:
vicrodh committed 2026-08-21 02:27:24 -06:00
1 parent 50469ccc57
commit 5d3d51a882
5 files changed
+680 -1

No files matched your search

+78 -1
View File
@@ -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"
+1
View File
@@ -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",
+14
View File
@@ -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"
+52
View File
@@ -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 -- <track#> <destdir>`
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<u32> = 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());
}
}
+535
View File
@@ -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<i32> },
}
/// 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<u32>,
pub tracks: Vec<RipTrack>,
}
#[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<i32>) {
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<Vec<u8>, 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<u32>,
) -> 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/<album artist> - <album>/NN - <title>.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(_))));
}
}