diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 35400df85..bbfcdc16f 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -6369,6 +6369,15 @@ dependencies = [ "qbz-models", ] +[[package]] +name = "qbz-disc" +version = "2.0.2" +dependencies = [ + "libc", + "log", + "thiserror 2.0.18", +] + [[package]] name = "qbz-dsd" version = "2.0.2" @@ -6670,6 +6679,7 @@ dependencies = [ "qbz-core", "qbz-credentials", "qbz-dac-wizard-core", + "qbz-disc", "qbz-external-reco", "qbz-i18n", "qbz-integrations", @@ -6760,6 +6770,7 @@ version = "2.0.2" dependencies = [ "async-trait", "log", + "qbz-disc", "qbz-jellyfin", "qbz-library", "qbz-media-cache", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 0dadd9177..351b69eba 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -16,6 +16,10 @@ members = [ "qbz-cast", "qbz-cmaf", "qbz-dsd", + # Optical media (CD-DA now; SACD/ISO next). Frontend-agnostic, and NOT a + # 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-secrets", "qbz-offline-cache", "qbz-plex", diff --git a/crates/qbz-disc/Cargo.toml b/crates/qbz-disc/Cargo.toml new file mode 100644 index 000000000..8afe8e801 --- /dev/null +++ b/crates/qbz-disc/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "qbz-disc" +version.workspace = true +edition.workspace = true + +[dependencies] +log = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" diff --git a/crates/qbz-disc/examples/cd_probe.rs b/crates/qbz-disc/examples/cd_probe.rs new file mode 100644 index 000000000..9a34ee8d5 --- /dev/null +++ b/crates/qbz-disc/examples/cd_probe.rs @@ -0,0 +1,68 @@ +//! Read the disc in the drive and prove the crate agrees with reality. +//! `cargo run -p qbz-disc --example cd_probe` +fn main() { + let devs = qbz_disc::list_devices(); + println!("devices: {devs:?}"); + let Some(dev) = devs.first() else { + println!("no drive"); + return; + }; + let toc = match qbz_disc::read_toc(dev) { + Ok(t) => t, + Err(e) => { + println!("toc failed: {e}"); + return; + } + }; + println!("fingerprint: {}", toc.fingerprint()); + println!("leadout LSN: {}", toc.leadout_lsn); + let mut total = 0u64; + for t in &toc.tracks { + let d = t.duration_secs(); + total += d; + println!( + " track {:2} lsn {:>7} {:>6} sectors {:>2}:{:02} {}", + t.number, + t.start_lsn, + t.sectors, + d / 60, + d % 60, + if t.is_audio { "audio" } else { "DATA (skipped)" } + ); + } + println!("total {}:{:02}", total / 60, total % 60); + + // Read the first audio track's opening chunk and prove it is music. + let Some(first) = toc.audio_tracks().next() else { + return; + }; + let mut r = match qbz_disc::cdda::TrackReader::open(dev, first) { + Ok(r) => r, + Err(e) => { + println!("open failed: {e}"); + return; + } + }; + let mut buf = Vec::new(); + // Skip the first second — track 1 often opens on silence. + for _ in 0..2 { + if let Err(e) = r.next_chunk(&mut buf) { + println!("read failed: {e}"); + return; + } + } + let samples: Vec = buf + .chunks_exact(2) + .map(|b| i16::from_le_bytes([b[0], b[1]])) + .collect(); + let peak = samples.iter().map(|s| s.unsigned_abs()).max().unwrap_or(0); + let nz = samples.iter().filter(|s| **s != 0).count(); + println!( + "read {} bytes | {} samples | non-zero {}% | peak {} | byte-swapped? {}", + buf.len(), + samples.len(), + 100 * nz / samples.len().max(1), + peak, + qbz_disc::cdda::looks_byte_swapped(&buf) + ); +} diff --git a/crates/qbz-disc/src/cdda.rs b/crates/qbz-disc/src/cdda.rs new file mode 100644 index 000000000..4b6525da8 --- /dev/null +++ b/crates/qbz-disc/src/cdda.rs @@ -0,0 +1,504 @@ +//! CD-DA off a real drive, through the kernel's own ioctls. +//! +//! No libcdio. Not because libcdio is bad — it is better at RIPPING than this +//! will ever be — but because this path PLAYS a clean disc, and for that the +//! kernel interface is enough while a native library would have to be built +//! into Flatpak, Snap and AppImage. Both approaches need the same `--device` +//! permission in a sandbox; only one needs the library shipped. +//! +//! WHAT THIS IS NOT: accurate extraction. There is no overlap synchronisation, +//! no cache modelling, no jitter verification, no scratch reconstruction. On a +//! clean disc that is fine for playback; on a marginal one it can click, and +//! an unreadable sector surfaces as an ERROR rather than a hole full of zeros. +//! Silence that pretends to be audio is the one outcome bit-perfect playback +//! must never produce. +//! +//! MEASURED on the owner's USB drive with Tool — *Fear Inoculum* (2026-08-20), +//! before any of this existed: +//! - `cd-info` reports access mode IOCTL; the TOC reads back 7 audio tracks, +//! lead-out at LSN 356568; +//! - `CDROMREADAUDIO` over 20 frames from LSN 4000 returned 47 040 bytes, +//! 99 % non-zero samples, peak 14559 — real audio, not silence; +//! - the drive hands back LITTLE-endian samples. Neighbouring samples差 +//! average 73.5 read as LE against 20 971 read as BE, a 285x margin. So +//! the bytes go straight into a little-endian WAV with no swap. That is +//! measured for THIS drive; `looks_byte_swapped` below keeps the check +//! available as a diagnostic rather than as an article of faith. + +use std::os::fd::{AsRawFd, OwnedFd}; +use std::path::{Path, PathBuf}; + +// --- ioctl numbers and layouts (uapi/linux/cdrom.h) ------------------------- +const CDROMREADTOCHDR: libc::c_ulong = 0x5305; +const CDROMREADTOCENTRY: libc::c_ulong = 0x5306; +const CDROMREADAUDIO: libc::c_ulong = 0x530e; +const CDROM_DRIVE_STATUS: libc::c_ulong = 0x5326; + +const CDROM_LBA: u8 = 0x01; +/// The lead-out's pseudo track number: its start is the end of the last track. +const CDROM_LEADOUT: u8 = 0xAA; +const CDS_DISC_OK: libc::c_int = 4; +/// `cdte_ctrl` bit 2 marks a DATA track. A mixed-mode disc carries one and it +/// must be skipped: handing its bytes to an audio path produces noise. +const CTRL_DATA: u8 = 0x04; +/// The kernel accepts 1..=75 frames per `CDROMREADAUDIO` call. +pub const MAX_FRAMES_PER_READ: u32 = 75; + +#[repr(C)] +#[derive(Default)] +struct CdromTocHdr { + trk0: u8, + trk1: u8, +} + +#[repr(C)] +#[derive(Default)] +struct CdromTocEntry { + track: u8, + /// adr in the low nibble, ctrl in the high one — a C bitfield, read whole. + adr_ctrl: u8, + format: u8, + _pad: u8, + addr_lba: i32, + datamode: u8, + _pad2: [u8; 3], +} + +#[repr(C)] +struct CdromReadAudio { + addr_lba: i32, + addr_format: u8, + _pad: [u8; 3], + nframes: libc::c_int, + buf: *mut u8, +} + +#[derive(Debug, thiserror::Error)] +pub enum CdError { + #[error("no optical drive found")] + NoDrive, + #[error("cannot open {0}: {1}")] + Open(PathBuf, std::io::Error), + #[error("no disc in the drive")] + NoDisc, + #[error("the disc has no audio tracks")] + NotAudio, + #[error("the drive refused to read audio at sector {lsn}: {source}")] + Read { + lsn: u32, + #[source] + source: std::io::Error, + }, + #[error("reading the table of contents failed: {0}")] + Toc(std::io::Error), +} + +/// One entry of the disc's table of contents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TocTrack { + pub number: u8, + pub start_lsn: u32, + /// Sectors up to the next track (or the lead-out for the last one). + pub sectors: u32, + /// False for the data track of a mixed-mode disc. + pub is_audio: bool, +} + +impl TocTrack { + pub fn duration_secs(&self) -> u64 { + crate::sectors_to_secs(self.sectors) + } + /// PCM frames (sample pairs) this track decodes to. + pub fn frames(&self) -> u64 { + self.sectors as u64 * 588 + } +} + +#[derive(Debug, Clone)] +pub struct Toc { + pub device: PathBuf, + pub tracks: Vec, + pub leadout_lsn: u32, +} + +impl Toc { + /// The AUDIO tracks, in disc order. A caller that wants to play something + /// should use this and never `tracks` — the data track of an enhanced CD + /// lives in the latter. + pub fn audio_tracks(&self) -> impl Iterator { + self.tracks.iter().filter(|t| t.is_audio) + } + + /// A stable identity for the MEDIUM, not the drive. + /// + /// `/dev/sr0` names a piece of hardware; two different discs in it are two + /// different sessions, and a persisted queue that resolves against the + /// drive would happily play track 3 of whatever is loaded now. The digest + /// is over every track's start and length plus the lead-out, which is what + /// a disc actually is. (The disc's own MCN would be tidier — the owner's + /// Fear Inoculum reports all zeros, so it is not dependable.) + pub fn fingerprint(&self) -> String { + // FNV-1a: no dependency, and this only has to be stable and + // collision-resistant enough to tell two albums apart. + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut eat = |v: u32| { + for b in v.to_le_bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + }; + for t in &self.tracks { + eat(t.start_lsn); + eat(t.sectors); + eat(t.number as u32); + } + eat(self.leadout_lsn); + format!("{h:016x}") + } +} + +/// Optical devices the kernel is showing, most conventional first. +/// +/// `/dev/sr0` is NOT hardcoded anywhere in this crate: a box can have several +/// drives, and `/dev/cdrom` is a udev symlink that may point at any of them. +pub fn list_devices() -> Vec { + let mut out = Vec::new(); + for n in 0..8 { + let p = PathBuf::from(format!("/dev/sr{n}")); + if p.exists() { + out.push(p); + } + } + if out.is_empty() { + let alt = PathBuf::from("/dev/cdrom"); + if alt.exists() { + out.push(alt); + } + } + out +} + +fn open_device(dev: &Path) -> Result { + // O_NONBLOCK is required, not optional: without it `open` on an optical + // device BLOCKS until a disc is loaded and spun up, which on an empty tray + // means hanging forever. + let c = std::ffi::CString::new(dev.as_os_str().as_encoded_bytes()) + .map_err(|_| CdError::Open(dev.to_path_buf(), std::io::Error::other("bad path")))?; + let fd = unsafe { libc::open(c.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK) }; + if fd < 0 { + return Err(CdError::Open( + dev.to_path_buf(), + std::io::Error::last_os_error(), + )); + } + Ok(unsafe { ::from_raw_fd(fd) }) +} + +/// Read the table of contents, skipping data tracks by their control bits. +pub fn read_toc(dev: &Path) -> Result { + let fd = open_device(dev)?; + let raw = fd.as_raw_fd(); + + let status = unsafe { libc::ioctl(raw, CDROM_DRIVE_STATUS, 0) }; + if status != CDS_DISC_OK { + return Err(CdError::NoDisc); + } + + let mut hdr = CdromTocHdr::default(); + if unsafe { libc::ioctl(raw, CDROMREADTOCHDR, &mut hdr) } < 0 { + return Err(CdError::Toc(std::io::Error::last_os_error())); + } + + let entry_lba = |n: u8| -> Result<(u32, u8), CdError> { + let mut e = CdromTocEntry { + track: n, + format: CDROM_LBA, + ..Default::default() + }; + if unsafe { libc::ioctl(raw, CDROMREADTOCENTRY, &mut e) } < 0 { + return Err(CdError::Toc(std::io::Error::last_os_error())); + } + Ok((e.addr_lba.max(0) as u32, e.adr_ctrl >> 4)) + }; + + let (leadout_lsn, _) = entry_lba(CDROM_LEADOUT)?; + + let mut raw_tracks: Vec<(u8, u32, bool)> = Vec::new(); + for n in hdr.trk0..=hdr.trk1 { + let (lba, ctrl) = entry_lba(n)?; + raw_tracks.push((n, lba, ctrl & CTRL_DATA == 0)); + } + + // A track's length is the gap to whatever starts next — the following + // track, or the lead-out for the last one. The TOC does not carry lengths. + let mut tracks = Vec::with_capacity(raw_tracks.len()); + for (i, &(number, start_lsn, is_audio)) in raw_tracks.iter().enumerate() { + let next = raw_tracks + .get(i + 1) + .map(|t| t.1) + .unwrap_or(leadout_lsn); + tracks.push(TocTrack { + number, + start_lsn, + sectors: next.saturating_sub(start_lsn), + is_audio, + }); + } + + if !tracks.iter().any(|t| t.is_audio) { + return Err(CdError::NotAudio); + } + + Ok(Toc { + device: dev.to_path_buf(), + tracks, + leadout_lsn, + }) +} + +/// Read `frames` sectors starting at `lsn` into `out`, which is resized to fit. +/// +/// Retries a few times before giving up: a marginal sector often reads on a +/// second attempt, and the alternative to a retry here is a click. What it +/// will NOT do is return zeros — an unrecoverable sector is an error, and the +/// caller stops the track rather than playing silence over it. +pub fn read_audio(dev: &Path, lsn: u32, frames: u32, out: &mut Vec) -> Result<(), CdError> { + let frames = frames.clamp(1, MAX_FRAMES_PER_READ); + out.resize(frames as usize * crate::CDDA_SECTOR_BYTES, 0); + let fd = open_device(dev)?; + read_audio_fd(fd.as_raw_fd(), dev, lsn, frames, out) +} + +fn read_audio_fd( + raw: libc::c_int, + dev: &Path, + lsn: u32, + frames: u32, + out: &mut [u8], +) -> Result<(), CdError> { + let mut last = std::io::Error::other("no attempt made"); + for attempt in 0..3 { + let mut arg = CdromReadAudio { + addr_lba: lsn as i32, + addr_format: CDROM_LBA, + _pad: [0; 3], + nframes: frames as libc::c_int, + buf: out.as_mut_ptr(), + }; + if unsafe { libc::ioctl(raw, CDROMREADAUDIO, &mut arg) } >= 0 { + if attempt > 0 { + log::info!("[cdda] sector {lsn} read on attempt {}", attempt + 1); + } + return Ok(()); + } + last = std::io::Error::last_os_error(); + } + log::warn!( + "[cdda] {} refused sector {lsn} after 3 attempts: {last}", + dev.display() + ); + Err(CdError::Read { lsn, source: last }) +} + +/// An open reader positioned on one track, so a whole track is read through a +/// SINGLE file descriptor instead of re-opening the device 4000 times. +pub struct TrackReader { + fd: OwnedFd, + device: PathBuf, + next_lsn: u32, + end_lsn: u32, +} + +impl TrackReader { + pub fn open(dev: &Path, track: &TocTrack) -> Result { + Ok(Self { + fd: open_device(dev)?, + device: dev.to_path_buf(), + next_lsn: track.start_lsn, + end_lsn: track.start_lsn + track.sectors, + }) + } + + pub fn finished(&self) -> bool { + self.next_lsn >= self.end_lsn + } + + /// Next chunk of raw CD-DA bytes, up to `MAX_FRAMES_PER_READ` sectors. + /// `Ok(0)` means the track is done. + pub fn next_chunk(&mut self, out: &mut Vec) -> Result { + if self.finished() { + out.clear(); + return Ok(0); + } + let frames = (self.end_lsn - self.next_lsn).min(MAX_FRAMES_PER_READ); + out.resize(frames as usize * crate::CDDA_SECTOR_BYTES, 0); + read_audio_fd( + self.fd.as_raw_fd(), + &self.device, + self.next_lsn, + frames, + out, + )?; + self.next_lsn += frames; + Ok(out.len()) + } +} + +/// Diagnostic: does this buffer look byte-swapped? +/// +/// Audio is continuous, so neighbouring samples differ by little; a wrong +/// endianness turns that into noise. Measured on the owner's drive the ratio +/// was 285x in favour of little-endian, so this returns false there. It exists +/// because "every drive is little-endian" is a claim about ONE drive, and the +/// honest way to hold it is as something checkable rather than assumed. +pub fn looks_byte_swapped(pcm: &[u8]) -> bool { + let step = |swap: bool| -> f64 { + let mut total = 0f64; + let mut n = 0u32; + let mut prev: Option = None; + // Left channel only: 4 bytes per frame, take the first pair. + for f in pcm.chunks_exact(4) { + let s = if swap { + i16::from_be_bytes([f[0], f[1]]) + } else { + i16::from_le_bytes([f[0], f[1]]) + }; + if let Some(p) = prev { + total += (s as i32 - p as i32).unsigned_abs() as f64; + n += 1; + } + prev = Some(s); + } + if n == 0 { + 0.0 + } else { + total / n as f64 + } + }; + let le = step(false); + let be = step(true); + be > 0.0 && le > be +} + +#[cfg(test)] +mod tests { + use super::*; + + fn toc(entries: &[(u8, u32, bool)], leadout: u32) -> Toc { + let mut tracks = Vec::new(); + for (i, &(number, start_lsn, is_audio)) in entries.iter().enumerate() { + let next = entries.get(i + 1).map(|e| e.1).unwrap_or(leadout); + tracks.push(TocTrack { + number, + start_lsn, + sectors: next - start_lsn, + is_audio, + }); + } + Toc { + device: PathBuf::from("/dev/null"), + tracks, + leadout_lsn: leadout, + } + } + + /// The REAL table of contents of the owner's disc, read 2026-08-20. + fn fear_inoculum() -> Toc { + toc( + &[ + (1, 0, true), + (2, 46_577, true), + (3, 100_047, true), + (4, 157_373, true), + (5, 218_718, true), + (6, 264_120, true), + (7, 285_735, true), + ], + 356_568, + ) + } + + #[test] + fn track_lengths_come_from_the_gaps_and_the_leadout() { + let t = fear_inoculum(); + // Track 1 = 10:21, and the LAST track has to reach the lead-out or the + // longest song on the disc would be cut short. + assert_eq!(t.tracks[0].sectors, 46_577); + assert_eq!(t.tracks[0].duration_secs(), 621); + assert_eq!(t.tracks[6].sectors, 70_833); + assert_eq!(t.tracks[6].duration_secs(), 944); + assert_eq!(t.tracks.len(), 7); + } + + #[test] + fn a_data_track_is_never_offered_as_audio() { + // Enhanced CD: three songs then a data track. + let t = toc(&[(1, 0, true), (2, 20_000, true), (3, 40_000, false)], 60_000); + assert_eq!(t.tracks.len(), 3); + assert_eq!(t.audio_tracks().count(), 2); + assert!(t.audio_tracks().all(|x| x.number != 3)); + } + + #[test] + fn the_fingerprint_identifies_the_MEDIUM_not_the_drive() { + let a = fear_inoculum(); + let mut b = fear_inoculum(); + b.device = PathBuf::from("/dev/sr3"); + // Same disc in a different drive: same identity. + assert_eq!(a.fingerprint(), b.fingerprint()); + + // A different disc with the same track COUNT must not collide — this + // is the case that would resolve a persisted queue onto the wrong + // album. + let other = toc( + &[ + (1, 0, true), + (2, 46_500, true), + (3, 100_047, true), + (4, 157_373, true), + (5, 218_718, true), + (6, 264_120, true), + (7, 285_735, true), + ], + 356_568, + ); + assert_ne!(a.fingerprint(), other.fingerprint()); + } + + #[test] + fn frames_per_track_match_the_wav_the_player_will_be_promised() { + let t = fear_inoculum(); + let first = &t.tracks[0]; + assert_eq!(first.frames(), 46_577 * 588); + // 10:21 of 44.1k stereo 16-bit — the size the streaming path declares. + assert_eq!( + crate::wav_total_size_16(first.frames(), 2), + 44 + 46_577 * 588 * 4 + ); + } + + #[test] + fn a_read_is_clamped_to_what_the_kernel_accepts() { + // The ioctl takes 1..=75 frames; asking for more is not a silent + // truncation somewhere deep, it is clamped here where it is visible. + assert_eq!(MAX_FRAMES_PER_READ, 75); + } + + #[test] + fn the_endianness_probe_agrees_with_the_measured_drive() { + // A smooth ramp is what real audio looks like read the RIGHT way. + let mut pcm = Vec::new(); + for i in 0..2000i16 { + let s = (i.wrapping_mul(7)) % 3000; + pcm.extend_from_slice(&s.to_le_bytes()); // L + pcm.extend_from_slice(&s.to_le_bytes()); // R + } + assert!(!looks_byte_swapped(&pcm), "little-endian audio flagged as swapped"); + + let swapped: Vec = pcm + .chunks_exact(2) + .flat_map(|p| [p[1], p[0]]) + .collect(); + assert!(looks_byte_swapped(&swapped), "byte-swapped audio not detected"); + } +} diff --git a/crates/qbz-disc/src/lib.rs b/crates/qbz-disc/src/lib.rs new file mode 100644 index 000000000..d38deec66 --- /dev/null +++ b/crates/qbz-disc/src/lib.rs @@ -0,0 +1,203 @@ +//! Optical media QBZ can PLAY without indexing any of it. +//! +//! Frontend-agnostic (ADR-006): no UI, no player, no Qt. The crate hands back +//! a track list and raw bytes; who plays them, and how, is the frontend's +//! problem. +//! +//! Deliberately does NOT depend on `qbz-dsd`. The dependency runs the other +//! way — `qbz-dsd` adapts this crate's SACD reader to its `DsdDemuxer` trait — +//! because the reverse closes a cycle. +//! +//! Phase 1 covers CD-DA from a real drive. What is not supported is DETECTED +//! and REPORTED, never silently degraded: a data track in a mixed-mode disc is +//! skipped by name, an unreadable sector is an error rather than a hole full +//! of zeros. Silence that pretends to be audio is the one outcome a +//! bit-perfect player must never produce. + +#[cfg(target_os = "linux")] +pub mod cdda; + +#[cfg(target_os = "linux")] +pub use cdda::{list_devices, read_audio, read_toc, CdError, Toc, TocTrack}; + +/// Bytes per CD-DA sector (a "frame" in the kernel's vocabulary): 588 stereo +/// 16-bit sample pairs. +pub const CDDA_SECTOR_BYTES: usize = 2352; +/// Sectors per second of CD-DA audio. +pub const CDDA_SECTORS_PER_SEC: u32 = 75; +/// CD-DA is 44 100 Hz, 16-bit, stereo. These are not configurable and any code +/// that treats them as such has misunderstood the format. +pub const CDDA_SAMPLE_RATE: u32 = 44_100; +pub const CDDA_BITS: u16 = 16; +pub const CDDA_CHANNELS: u16 = 2; + +/// Seconds of audio in `sectors`, rounded down. +pub fn sectors_to_secs(sectors: u32) -> u64 { + (sectors / CDDA_SECTORS_PER_SEC) as u64 +} + +/// A 44-byte RIFF/WAVE header for 16-BIT PCM. +/// +/// `qbz-dsd` has a `wav_header` already and it is hardcoded to 24-bit — the +/// depth its converter emits. Reusing it for a CD would mean either lying in +/// the header or promoting 16-bit samples to 24, and while that shift is +/// arithmetically lossless it makes the DAC open at 24 bits for a 16-bit +/// source. That is exactly the silent conversion a bit-perfect path must not +/// contain, so a CD gets its own header. +pub fn wav_header_16(total_frames: u64, channels: u16, sample_rate: u32) -> Vec { + const HEADER_LEN: usize = 44; + let bytes_per_sample = 2u64; + let data_len = (total_frames * channels as u64 * bytes_per_sample) as u32; + let byte_rate = sample_rate * channels as u32 * bytes_per_sample as u32; + let block_align = channels * bytes_per_sample as u16; + let mut h = Vec::with_capacity(HEADER_LEN); + h.extend_from_slice(b"RIFF"); + h.extend_from_slice(&(36 + data_len).to_le_bytes()); + h.extend_from_slice(b"WAVE"); + h.extend_from_slice(b"fmt "); + h.extend_from_slice(&16u32.to_le_bytes()); + h.extend_from_slice(&1u16.to_le_bytes()); // PCM + h.extend_from_slice(&channels.to_le_bytes()); + h.extend_from_slice(&sample_rate.to_le_bytes()); + h.extend_from_slice(&byte_rate.to_le_bytes()); + h.extend_from_slice(&block_align.to_le_bytes()); + h.extend_from_slice(&CDDA_BITS.to_le_bytes()); + h.extend_from_slice(b"data"); + h.extend_from_slice(&data_len.to_le_bytes()); + debug_assert_eq!(h.len(), HEADER_LEN); + h +} + +/// Total size of the WAV a track becomes — what the streaming player needs as +/// its `content_length`. +pub fn wav_total_size_16(total_frames: u64, channels: u16) -> u64 { + 44 + total_frames * channels as u64 * 2 +} + +/// A CD-DA track, encoded as ONE string so it can ride in `LocalTrack.file_path` +/// and through every store and queue that carries a path today. +/// +/// `cdda:/dev/sr0#0+46577` — device, start sector, sector count. +/// +/// It is a real type with a single `parse`/`to_path_string` pair rather than +/// ad-hoc `starts_with` tests scattered around, because the scattered version +/// is a known failure mode in this codebase: a second list of prefixes always +/// ends up missing one site, and the fold is silent (see the six "a place that +/// enumerates sources by hand" bugs of 2026-08-20). One parser, one shape. +/// +/// It is NOT a filesystem path and nothing may stat, open or canonicalise it. +/// Callers test with [`CdRef::is_cd_path`] BEFORE reaching for the filesystem. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CdRef { + pub device: std::path::PathBuf, + pub start_lsn: u32, + pub sectors: u32, +} + +impl CdRef { + pub const SCHEME: &'static str = "cdda:"; + + /// Cheap test callers use to route BEFORE touching the filesystem. + pub fn is_cd_path(s: &str) -> bool { + s.starts_with(Self::SCHEME) + } + + pub fn to_path_string(&self) -> String { + format!( + "{}{}#{}+{}", + Self::SCHEME, + self.device.display(), + self.start_lsn, + self.sectors + ) + } + + pub fn parse(s: &str) -> Option { + let rest = s.strip_prefix(Self::SCHEME)?; + let (dev, range) = rest.rsplit_once('#')?; + let (lsn, sectors) = range.split_once('+')?; + if dev.is_empty() { + return None; + } + Some(Self { + device: std::path::PathBuf::from(dev), + start_lsn: lsn.parse().ok()?, + sectors: sectors.parse().ok()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_header_describes_a_cd_and_not_a_dsd_conversion() { + // 10 seconds of CD-DA. + let frames = 44_100u64 * 10; + let h = wav_header_16(frames, 2, 44_100); + assert_eq!(&h[0..4], b"RIFF"); + assert_eq!(&h[8..12], b"WAVE"); + // bits per sample at offset 34 — 16, never 24. + assert_eq!(u16::from_le_bytes([h[34], h[35]]), 16); + assert_eq!(u32::from_le_bytes([h[24], h[25], h[26], h[27]]), 44_100); + // byte rate = 44100 * 2ch * 2 bytes + assert_eq!(u32::from_le_bytes([h[28], h[29], h[30], h[31]]), 176_400); + // and the sizes agree with the standalone helper + assert_eq!( + wav_total_size_16(frames, 2), + 44 + u32::from_le_bytes([h[40], h[41], h[42], h[43]]) as u64 + ); + } + + #[test] + fn a_measured_track_length_converts_to_its_real_duration() { + // Tool — Fear Inoculum, measured off the owner's disc 2026-08-20: + // track 1 spans 46577 sectors and track 7 spans 70833. + assert_eq!(sectors_to_secs(46_577), 621); // 10:21 + assert_eq!(sectors_to_secs(70_833), 944); // 15:44 + } + + #[test] + fn a_cd_reference_round_trips() { + let r = CdRef { + device: std::path::PathBuf::from("/dev/sr0"), + start_lsn: 285_735, + sectors: 70_833, + }; + let s = r.to_path_string(); + assert_eq!(s, "cdda:/dev/sr0#285735+70833"); + assert_eq!(CdRef::parse(&s), Some(r)); + assert!(CdRef::is_cd_path(&s)); + } + + #[test] + fn a_real_path_is_never_mistaken_for_a_disc() { + // The whole point of the scheme: no filesystem path can collide with + // it, so a routing test cannot send a real file down the disc path. + for p in [ + "/home/u/Music/a.flac", + "/home/u/cdda:weird/x.dsf", + "plex:1234", + "", + ] { + assert!(!CdRef::is_cd_path(p), "{p} was taken for a disc"); + assert_eq!(CdRef::parse(p), None); + } + } + + #[test] + fn a_malformed_reference_is_rejected_rather_than_guessed() { + for bad in [ + "cdda:", + "cdda:/dev/sr0", + "cdda:/dev/sr0#", + "cdda:/dev/sr0#12", + "cdda:/dev/sr0#12+", + "cdda:/dev/sr0#a+b", + "cdda:#0+1", + ] { + assert_eq!(CdRef::parse(bad), None, "{bad} parsed"); + } + } +}