diff --git a/crates/qbz-rip/examples/rip_one.rs b/crates/qbz-rip/examples/rip_one.rs index e4fc03092..00d4d8acd 100644 --- a/crates/qbz-rip/examples/rip_one.rs +++ b/crates/qbz-rip/examples/rip_one.rs @@ -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; diff --git a/crates/qbz-rip/src/lib.rs b/crates/qbz-rip/src/lib.rs index fd5a5e7a8..e76ca8a96 100644 --- a/crates/qbz-rip/src/lib.rs +++ b/crates/qbz-rip/src/lib.rs @@ -46,6 +46,17 @@ pub struct RipPlan { pub album_artist: String, pub year: Option, pub tracks: Vec, + /// 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, + /// This crate's own TOC hash — the key the app remembers the disc under. + pub toc_fingerprint: Option, + /// 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, } #[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 = 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, + /// Stereo frames, i.e. samples per channel. + frames: usize, + bytes: usize, + md5: Option, +} + +/// 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 { + 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) +}