feat(rip): a provenance log and the cover, beside the tracks
qbz-rip.log is deliberately NOT an EAC log. An EAC or XLD log is trusted for what it PROVES — test-and-copy, a drive read-offset correction, an AccurateRip cross-check against thousands of other rips of the same disc — and QBZ does none of the three. A file that imitated the format without the method would be worse than no file: it invites a claim this ripper cannot back, and the people who read rip logs are exactly the people who check. So the header says what it is and is not in the first block, not in small print. What remains is true and useful: which disc (DiscID and TOC fingerprint, both recomputable by anyone holding it), which drive, and per track the audio md5 that already lives in the file's own STREAMINFO — verifiable with flac -t without QBZ. Verified end to end: metaflac --show-md5sum prints exactly what the log claims, and flac -t passes on every track. "0 read errors" is a real promise and is stated as one: an unreadable sector ABORTS the rip rather than being written as silence, which is stronger than a burst-mode EAC rip. Where the OS did the reading there is no drive to name, and the log says that instead of printing a volume as though it were hardware. A partial rip declares itself partial: three tracks with no such line read as the whole album. cover.jpg keeps the source's EXTENSION — renaming a PNG to .jpg is how a picture stops opening. Neither sidecar can fail the rip: the audio is the deliverable, and a missing extra is a blemish on a folder of correct files. Five tests, including that the log states all three things it does not do, that the logged digest IS the file's own, and the calendar arithmetic that is the only thing here that could be quietly wrong for years. cc
This commit is contained in:
2 files changed
+388
No files matched your search
@@ -32,6 +32,10 @@ fn main() {
|
||||
sectors: t.sectors,
|
||||
},
|
||||
}],
|
||||
disc_id: Some(id.clone()),
|
||||
toc_fingerprint: Some(toc.fingerprint()),
|
||||
disc_track_count: toc.audio_tracks().count(),
|
||||
cover: None,
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
let mut last = -1i32;
|
||||
|
||||
@@ -46,6 +46,17 @@ pub struct RipPlan {
|
||||
pub album_artist: String,
|
||||
pub year: Option<u32>,
|
||||
pub tracks: Vec<RipTrack>,
|
||||
/// MusicBrainz DiscID, when the disc had one. Goes in the log: it is the
|
||||
/// one identity anybody else can recompute from the same disc.
|
||||
pub disc_id: Option<String>,
|
||||
/// This crate's own TOC hash — the key the app remembers the disc under.
|
||||
pub toc_fingerprint: Option<String>,
|
||||
/// How many tracks the DISC has, when the plan covers only some of them.
|
||||
/// A partial rip that does not say so reads as a disc with four tracks.
|
||||
pub disc_track_count: usize,
|
||||
/// An image to drop in the album folder as `cover.jpg`. Copied, never
|
||||
/// moved — the source is the app's artwork cache and is not ours.
|
||||
pub cover: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -262,6 +273,7 @@ where
|
||||
|
||||
let total = plan.tracks.len();
|
||||
let mut written = Vec::with_capacity(total);
|
||||
let mut receipts: Vec<Receipt> = Vec::with_capacity(total);
|
||||
|
||||
for (i, track) in plan.tracks.iter().enumerate() {
|
||||
let name = format!(
|
||||
@@ -359,6 +371,18 @@ where
|
||||
samples.len(),
|
||||
flac.len()
|
||||
);
|
||||
receipts.push(Receipt {
|
||||
number: track.number,
|
||||
title: track.title.clone(),
|
||||
file: name,
|
||||
sectors: match &track.source {
|
||||
RipSource::Cd { sectors, .. } => Some(*sectors),
|
||||
RipSource::Samples { .. } => None,
|
||||
},
|
||||
frames: samples.len() / 2,
|
||||
bytes: flac.len(),
|
||||
md5: audio_md5(&flac),
|
||||
});
|
||||
written.push(path);
|
||||
|
||||
if !progress(Progress {
|
||||
@@ -369,9 +393,76 @@ where
|
||||
return Err(RipError::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
// The two things that make a folder self-describing. Neither is allowed to
|
||||
// fail the rip: the audio is the deliverable, and a missing sidecar is a
|
||||
// blemish on a folder full of correct files.
|
||||
if let Some(src) = plan.cover.as_ref() {
|
||||
if let Err(e) = copy_cover(src, &folder) {
|
||||
log::warn!("[rip] cover not copied: {e}");
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(folder.join(LOG_NAME), write_log(plan, &receipts)) {
|
||||
log::warn!("[rip] log not written: {e}");
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// The file name the log lands under, beside the FLACs.
|
||||
pub const LOG_NAME: &str = "qbz-rip.log";
|
||||
|
||||
/// What one finished track is on record as being.
|
||||
struct Receipt {
|
||||
number: u32,
|
||||
title: String,
|
||||
file: String,
|
||||
/// `None` for a file source (the macOS mounted-CD path), where "sectors"
|
||||
/// is not a fact about anything.
|
||||
sectors: Option<u32>,
|
||||
/// Stereo frames, i.e. samples per channel.
|
||||
frames: usize,
|
||||
bytes: usize,
|
||||
md5: Option<String>,
|
||||
}
|
||||
|
||||
/// The MD5 of the DECODED audio, read back out of the file's own STREAMINFO.
|
||||
///
|
||||
/// `flacenc` computes it while encoding and writes it there, so this is not a
|
||||
/// second checksum invented for the log — it is the one already inside every
|
||||
/// FLAC, which is why `flac -t` and `metaflac --show-md5sum` can check it
|
||||
/// without QBZ being involved at all.
|
||||
///
|
||||
/// STREAMINFO is 34 bytes and the digest is its last 16: "fLaC" (4) + block
|
||||
/// header (4) + 18 bytes of stream fields, then the digest. An all-zero digest
|
||||
/// means the encoder declined to compute one and is reported as absent rather
|
||||
/// than as a checksum of zeros.
|
||||
fn audio_md5(flac: &[u8]) -> Option<String> {
|
||||
const AT: usize = 8 + 18;
|
||||
if flac.len() < AT + 16 || &flac[0..4] != b"fLaC" {
|
||||
return None;
|
||||
}
|
||||
let digest = &flac[AT..AT + 16];
|
||||
if digest.iter().all(|b| *b == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(digest.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// Put the artwork in the folder under the name every scanner already looks
|
||||
/// for. The EXTENSION follows the source, because renaming a PNG to .jpg is
|
||||
/// how a picture stops opening.
|
||||
fn copy_cover(src: &Path, folder: &Path) -> std::io::Result<()> {
|
||||
let ext = src
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_ascii_lowercase())
|
||||
.filter(|e| matches!(e.as_str(), "jpg" | "jpeg" | "png" | "webp"))
|
||||
.unwrap_or_else(|| "jpg".to_string());
|
||||
let ext = if ext == "jpeg" { "jpg".to_string() } else { ext };
|
||||
std::fs::copy(src, folder.join(format!("cover.{ext}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -472,6 +563,10 @@ mod tests {
|
||||
source: RipSource::Samples { pcm: vec![0i32; 8820] },
|
||||
},
|
||||
],
|
||||
disc_id: Some("BeNBMsD8Du5NO2W61Yk.B2jwwIs-".into()),
|
||||
toc_fingerprint: Some("38bef21351f7fca3".into()),
|
||||
disc_track_count: 7,
|
||||
cover: None,
|
||||
};
|
||||
let out = rip(&plan, |_| true).expect("rip");
|
||||
assert_eq!(out.len(), 2);
|
||||
@@ -500,6 +595,117 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The log is a promise about what it does NOT do. If those three lines
|
||||
/// ever drift out, the file starts reading like an EAC log, which is the
|
||||
/// one thing it must never be mistaken for.
|
||||
#[test]
|
||||
fn the_log_says_what_it_is_not_before_it_says_anything_else() {
|
||||
let dir = std::env::temp_dir().join(format!("qbz-rip-log-{}", 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: 2,
|
||||
title: "Pneuma".into(),
|
||||
artist: "Tool".into(),
|
||||
source: RipSource::Samples { pcm: vec![0i32; 8820] },
|
||||
}],
|
||||
disc_id: Some("BeNBMsD8Du5NO2W61Yk.B2jwwIs-".into()),
|
||||
toc_fingerprint: Some("38bef21351f7fca3".into()),
|
||||
// ONE track written out of SEVEN on the disc — a partial rip.
|
||||
disc_track_count: 7,
|
||||
cover: None,
|
||||
};
|
||||
rip(&plan, |_| true).expect("rip");
|
||||
|
||||
let log = std::fs::read_to_string(dir.join("Tool - Fear Inoculum").join(LOG_NAME))
|
||||
.expect("the log is written beside the files");
|
||||
for disclaimer in ["test-and-copy", "read offset", "AccurateRip"] {
|
||||
assert!(
|
||||
log.contains(disclaimer),
|
||||
"the log must say it does not do {disclaimer}"
|
||||
);
|
||||
}
|
||||
// The identity anybody else can recompute.
|
||||
assert!(log.contains("BeNBMsD8Du5NO2W61Yk.B2jwwIs-"));
|
||||
assert!(log.contains("38bef21351f7fca3"));
|
||||
// A partial rip has to SAY it is partial, or one track reads as the
|
||||
// whole album.
|
||||
assert!(log.contains("1 of 7"), "partial rips are named as such:\n{log}");
|
||||
assert!(log.contains("Pneuma"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The digest in the log has to be the one INSIDE the file, or `flac -t`
|
||||
/// and this log disagree and the log is the thing people would trust.
|
||||
#[test]
|
||||
fn the_logged_md5_is_the_files_own_streaminfo_digest() {
|
||||
let flac = encode_flac(&vec![0i32; 8820], 2, 44_100).expect("encode");
|
||||
let logged = audio_md5(&flac).expect("a digest");
|
||||
assert_eq!(logged.len(), 32, "32 hex characters");
|
||||
// Byte for byte the STREAMINFO tail: "fLaC" + 4-byte block header +
|
||||
// 18 bytes of fields, then the 16-byte digest.
|
||||
let from_file: String = flac[26..42].iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(logged, from_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_a_flac_yields_no_digest() {
|
||||
assert!(audio_md5(b"not a flac at all, really").is_none());
|
||||
// An all-zero digest means the encoder declined to compute one; that
|
||||
// is ABSENT, not "the checksum of an empty file".
|
||||
let mut fake = vec![0u8; 42];
|
||||
fake[0..4].copy_from_slice(b"fLaC");
|
||||
assert!(audio_md5(&fake).is_none());
|
||||
}
|
||||
|
||||
/// A picture renamed to the wrong extension is a picture that stops
|
||||
/// opening, so the copy follows the source.
|
||||
#[test]
|
||||
fn the_cover_keeps_its_own_format_and_lands_beside_the_tracks() {
|
||||
let dir = std::env::temp_dir().join(format!("qbz-rip-cover-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let src = dir.join("art.png");
|
||||
std::fs::write(&src, b"\x89PNG\r\n\x1a\n").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] },
|
||||
}],
|
||||
disc_id: None,
|
||||
toc_fingerprint: None,
|
||||
disc_track_count: 1,
|
||||
cover: Some(src),
|
||||
};
|
||||
rip(&plan, |_| true).expect("rip");
|
||||
let folder = dir.join("B - A");
|
||||
assert!(folder.join("cover.png").is_file(), "a PNG stays a PNG");
|
||||
assert!(!folder.join("cover.jpg").exists());
|
||||
// A cover that cannot be copied must not fail the rip.
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The date arithmetic is the only place in this file that could be
|
||||
/// quietly wrong for years. Two known dates, one of them a leap day.
|
||||
#[test]
|
||||
fn the_calendar_conversion_is_right_on_the_days_that_break_it() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
|
||||
assert_eq!(civil_from_days(-1), (1969, 12, 31));
|
||||
// 2000 is a leap year, 1900 was not — the two the naive rule gets
|
||||
// backwards.
|
||||
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_stops_and_leaves_no_partial_track() {
|
||||
let dir = std::env::temp_dir().join(format!("qbz-rip-cancel-{}", std::process::id()));
|
||||
@@ -515,6 +721,10 @@ mod tests {
|
||||
artist: "B".into(),
|
||||
source: RipSource::Samples { pcm: vec![0i32; 8820] },
|
||||
}],
|
||||
disc_id: None,
|
||||
toc_fingerprint: None,
|
||||
disc_track_count: 1,
|
||||
cover: None,
|
||||
};
|
||||
let err = rip(&plan, |_| false).unwrap_err();
|
||||
assert!(matches!(err, RipError::Cancelled));
|
||||
@@ -529,7 +739,181 @@ mod tests {
|
||||
album_artist: "B".into(),
|
||||
year: None,
|
||||
tracks: vec![],
|
||||
disc_id: None,
|
||||
toc_fingerprint: None,
|
||||
disc_track_count: 0,
|
||||
cover: None,
|
||||
};
|
||||
assert!(matches!(rip(&plan, |_| true), Err(RipError::Destination(_))));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A PROVENANCE record, deliberately not an EAC log.
|
||||
///
|
||||
/// An EAC or XLD log is trusted for what it PROVES, not for how it looks:
|
||||
/// test-and-copy (rip twice, compare), a drive read-offset correction, and an
|
||||
/// AccurateRip cross-check against thousands of other people's rips of the
|
||||
/// same disc. QBZ does none of the three. A file that imitated that format
|
||||
/// without the method would be worse than no file at all — it invites a claim
|
||||
/// this ripper cannot back, and the people who read rip logs are exactly the
|
||||
/// people who would check.
|
||||
///
|
||||
/// So the header says what this is and is not, in the first block, not in
|
||||
/// small print. What is left is genuinely useful and genuinely true:
|
||||
///
|
||||
/// * WHICH DISC — the MusicBrainz DiscID and the TOC fingerprint, both
|
||||
/// recomputable by anyone holding the same disc.
|
||||
/// * WHICH DRIVE — straight from sysfs.
|
||||
/// * PER TRACK — the audio MD5 that already lives in the file's STREAMINFO,
|
||||
/// verifiable with `flac -t` without QBZ. It checks the files against
|
||||
/// THEMSELVES (bit rot, a bad copy), not against the world.
|
||||
/// * READ ERRORS — zero, and it means zero: an unreadable sector aborts the
|
||||
/// rip rather than being written as silence. Which is a stronger promise
|
||||
/// than a burst-mode EAC rip makes.
|
||||
///
|
||||
/// English, because it is a technical file that gets shared.
|
||||
fn write_log(plan: &RipPlan, receipts: &[Receipt]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut s = String::with_capacity(2048);
|
||||
|
||||
let _ = writeln!(s, "QBZ rip log");
|
||||
let _ = writeln!(s, "===========");
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "WHAT THIS IS, AND WHAT IT IS NOT");
|
||||
let _ = writeln!(s, " A record of where these files came from, not a verification log.");
|
||||
let _ = writeln!(s, " QBZ does NOT do test-and-copy, does NOT apply a drive read offset,");
|
||||
let _ = writeln!(s, " and does NOT check AccurateRip. The checksums below verify these");
|
||||
let _ = writeln!(s, " files against themselves; they will not match an EAC or XLD log,");
|
||||
let _ = writeln!(s, " and are not meant to.");
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, " What it does promise: a sector that cannot be read ABORTS the rip.");
|
||||
let _ = writeln!(s, " Silence is never written in place of audio, so \"0 read errors\"");
|
||||
let _ = writeln!(s, " below means the disc was read in full or you would have no files.");
|
||||
let _ = writeln!(s);
|
||||
|
||||
let year = plan.year.map(|y| format!(" ({y})")).unwrap_or_default();
|
||||
let _ = writeln!(s, "Ripped {}", now_utc());
|
||||
let _ = writeln!(s, "QBZ {}", env!("CARGO_PKG_VERSION"));
|
||||
let _ = writeln!(s, "Album {} - {}{}", plan.album_artist, plan.album, year);
|
||||
|
||||
let drive = plan.tracks.iter().find_map(|t| match &t.source {
|
||||
RipSource::Cd { device, .. } => Some(device.clone()),
|
||||
RipSource::Samples { .. } => None,
|
||||
});
|
||||
match drive.as_ref().map(|d| (d, qbz_disc::drive_model(d))) {
|
||||
Some((dev, Some(model))) => {
|
||||
let _ = writeln!(s, "Drive {} ({model})", dev.display());
|
||||
}
|
||||
// A "device" with no model behind it is the macOS shape: the OS mounted
|
||||
// the disc and these bytes came out of ITS reader, not ours. Printing
|
||||
// "Drive /Volumes/Fear Inoculum (unknown model)" would name a volume as
|
||||
// if it were hardware, which is the log's first chance to lie.
|
||||
Some((dev, None)) => {
|
||||
let _ = writeln!(s, "Source {}", dev.display());
|
||||
let _ = writeln!(s, " read by the operating system, not by QBZ");
|
||||
}
|
||||
None => {
|
||||
let _ = writeln!(s, "Source files");
|
||||
}
|
||||
}
|
||||
let _ = writeln!(s, "Read offset none applied");
|
||||
let _ = writeln!(s, "Encoder flacenc 0.5, default settings, fixed block size");
|
||||
let _ = writeln!(s, "Read errors 0");
|
||||
let _ = writeln!(s);
|
||||
|
||||
let _ = writeln!(s, "Disc");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" MusicBrainz DiscID {}",
|
||||
plan.disc_id.as_deref().unwrap_or("not computed")
|
||||
);
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" TOC fingerprint {}",
|
||||
plan.toc_fingerprint.as_deref().unwrap_or("not computed")
|
||||
);
|
||||
// A partial rip that does not say so reads as a four-track album.
|
||||
if plan.disc_track_count > receipts.len() {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" Tracks {} of {} on the disc (partial rip)",
|
||||
receipts.len(),
|
||||
plan.disc_track_count
|
||||
);
|
||||
} else {
|
||||
let _ = writeln!(s, " Tracks {}", receipts.len());
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
|
||||
let _ = writeln!(s, "Tracks");
|
||||
for r in receipts {
|
||||
let _ = writeln!(s, " {:>2} {}", r.number, r.title);
|
||||
let _ = writeln!(s, " file {}", r.file);
|
||||
let secs = r.frames as f64 / 44_100.0;
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" length {}:{:05.2} frames {} flac {} bytes",
|
||||
(secs / 60.0) as u64,
|
||||
secs % 60.0,
|
||||
r.frames,
|
||||
r.bytes
|
||||
);
|
||||
if let Some(n) = r.sectors {
|
||||
let _ = writeln!(s, " sectors {n}");
|
||||
}
|
||||
match &r.md5 {
|
||||
Some(md5) => {
|
||||
let _ = writeln!(s, " audio md5 {md5}");
|
||||
}
|
||||
None => {
|
||||
let _ = writeln!(s, " audio md5 not recorded by the encoder");
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "Verify these files without QBZ:");
|
||||
let _ = writeln!(s, " flac -t *.flac (checks each file against its own md5)");
|
||||
let _ = writeln!(s, " metaflac --show-md5sum *.flac (prints the digests listed above)");
|
||||
s
|
||||
}
|
||||
|
||||
/// `2026-08-21 13:20:11 UTC`, with no dependency on a calendar crate.
|
||||
///
|
||||
/// UTC and not local time on purpose: a log is read on a different machine, in
|
||||
/// a different place, years later, and a bare local timestamp with no offset is
|
||||
/// the kind of fact that quietly means nothing.
|
||||
fn now_utc() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
|
||||
rem / 3600,
|
||||
(rem % 3600) / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `civil_from_days` — the standard days-since-epoch to
|
||||
/// calendar conversion, correct across leap years and centuries. Written out
|
||||
/// rather than pulled in, because one timestamp is not worth a dependency and
|
||||
/// this algorithm has not changed since 2013.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
Reference in new issue
Block a user