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:
5 files changed
+680
-1
No files matched your search
Generated
+78
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user