diff --git a/.github/workflows/build-qt-linux.yml b/.github/workflows/build-qt-linux.yml index 4c6ce8204..07da9a00c 100644 --- a/.github/workflows/build-qt-linux.yml +++ b/.github/workflows/build-qt-linux.yml @@ -228,11 +228,15 @@ jobs: scripts/packaging/desktop_entry_check.sh --appdir AppDir # Nothing in the app uses Qt SQL / network-information plugins. rm -rf AppDir/usr/plugins/sqldrivers AppDir/usr/plugins/networkinformation + mkdir -p AppDir/usr/share/licenses/qbz + cp LICENSE AppDir/usr/share/licenses/qbz/LICENSE + cp -r licenses AppDir/usr/share/licenses/qbz/third-party echo "platforms: $(find AppDir/usr/plugins/platforms -maxdepth 1 -type f -printf '%f ')" test -f AppDir/usr/plugins/platforms/libqwayland-generic.so test -f AppDir/usr/plugins/platforms/libqoffscreen.so test -f AppDir/usr/qml/QtQuick/Effects/libeffectsplugin.so test -f AppDir/usr/bin/qt.conf + test -f AppDir/usr/share/licenses/qbz/third-party/Apache-2.0-dst-decoder.txt du -sh AppDir # THE gate. Runs the AppDir with the Qt install removed from the diff --git a/.github/workflows/release-flatpak.yml b/.github/workflows/release-flatpak.yml index ba705a198..92e6d0788 100644 --- a/.github/workflows/release-flatpak.yml +++ b/.github/workflows/release-flatpak.yml @@ -143,6 +143,7 @@ jobs: cp packaging/flatpak/com.blitzfc.qbz.desktop "$P/qbz.desktop" cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "$P/" cp LICENSE "$P/LICENSE" + cp -r licenses "$P/licenses" mkdir -p "$P/icons"; cp packaging/icons/{32x32,48x48,64x64,128x128,256x256,512x512}.png "$P/icons/" ls -la "$P" diff --git a/.github/workflows/release-linux-aarch64.yml b/.github/workflows/release-linux-aarch64.yml index 62dc92f14..2c47d72ba 100644 --- a/.github/workflows/release-linux-aarch64.yml +++ b/.github/workflows/release-linux-aarch64.yml @@ -10,7 +10,7 @@ # distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working. # # CONTRACT (do not break): the release must keep publishing -# qbz_${VERSION}_arm64.tar.gz (dir qbz_${VERSION}_arm64/{qbz, qbzd, LICENSE, qbz.desktop, +# qbz_${VERSION}_arm64.tar.gz (dir qbz_${VERSION}_arm64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop, # com.blitzfc.qbz.metainfo.xml, qbzd.service, # completions/, icons/hicolor/...}) # QBZ_${VERSION}_arm64.deb / QBZ-${VERSION}-1.aarch64.rpm / QBZ_${VERSION}_arm64.AppImage @@ -290,6 +290,7 @@ jobs: cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop" cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml" cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE" + cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses" cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd" chmod +x "tarball-staging/${TARBALL_DIR}/qbzd" cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service" @@ -312,6 +313,7 @@ jobs: cp qbzd.fish "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish" cp packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md" cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE" + cp -r licenses "qbzd-tarball-staging/${QBZD_DIR}/licenses" tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}" ls -la dist diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 1e658c8f1..e7d7a3efc 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -10,7 +10,7 @@ # distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working. # # CONTRACT (do not break): the release must keep publishing -# qbz_${VERSION}_amd64.tar.gz (dir qbz_${VERSION}_amd64/{qbz, qbzd, LICENSE, qbz.desktop, +# qbz_${VERSION}_amd64.tar.gz (dir qbz_${VERSION}_amd64/{qbz, qbzd, LICENSE, licenses/, qbz.desktop, # com.blitzfc.qbz.metainfo.xml, qbzd.service, # completions/, icons/hicolor/...}) # QBZ_${VERSION}_amd64.deb / QBZ-${VERSION}-1.x86_64.rpm / QBZ_${VERSION}_amd64.AppImage @@ -294,6 +294,7 @@ jobs: cp packaging/linux/qbz.desktop "tarball-staging/${TARBALL_DIR}/qbz.desktop" cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml" cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE" + cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses" cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd" chmod +x "tarball-staging/${TARBALL_DIR}/qbzd" cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service" @@ -316,6 +317,7 @@ jobs: cp qbzd.fish "qbzd-tarball-staging/${QBZD_DIR}/completions/qbzd.fish" cp packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md" cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE" + cp -r licenses "qbzd-tarball-staging/${QBZD_DIR}/licenses" tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}" ls -la dist diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 6f3bb0b23..9b308bffe 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -166,6 +166,8 @@ jobs: cp "crates/target/${{ matrix.target }}/release/qbz" "$APP/Contents/MacOS/qbz" chmod +x "$APP/Contents/MacOS/qbz" cp packaging/icons/icon.icns "$APP/Contents/Resources/icon.icns" + cp LICENSE "$APP/Contents/Resources/LICENSE.txt" + cp -r licenses "$APP/Contents/Resources/licenses" # The Liquid Glass app icon (packaging/icons/QBZ.icon, Icon Composer # source, issue #712): compiled by Xcode 26's actool into # Assets.car + CFBundleIconName=QBZ. macOS 26 renders the layered diff --git a/.github/workflows/release-snap.yml b/.github/workflows/release-snap.yml index 9323a4c58..c8d66dbd9 100644 --- a/.github/workflows/release-snap.yml +++ b/.github/workflows/release-snap.yml @@ -154,6 +154,9 @@ jobs: cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/ cp packaging/snap/snap/gui/qbz.desktop snap-payload/qbz.desktop cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png + cp LICENSE snap-payload/LICENSE + cp -r licenses snap-payload/licenses + test -f snap-payload/licenses/Apache-2.0-dst-decoder.txt mkdir -p snap cp packaging/snap-qt/snapcraft.yaml snap/snapcraft.yaml sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml @@ -207,6 +210,9 @@ jobs: cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/ cp packaging/snap/snap/gui/qbz.desktop snap-payload/qbz.desktop cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png + cp LICENSE snap-payload/LICENSE + cp -r licenses snap-payload/licenses + test -f snap-payload/licenses/Apache-2.0-dst-decoder.txt mkdir -p snap cp packaging/snap-qt/snapcraft-arm64.yaml snap/snapcraft.yaml sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 5b753eda0..516c3ad88 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -1493,6 +1493,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dst-decoder" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22ef63dfde0eac89eca20926f8bb64eec4bf12016647274a51d3bb11f6147c" +dependencies = [ + "anyhow", +] + [[package]] name = "dunce" version = "1.0.5" @@ -4200,6 +4209,7 @@ version = "2.1.0" dependencies = [ "base64 0.23.1", "dirs 6.0.0", + "encoding_rs", "libc", "log", "plist", @@ -4217,6 +4227,7 @@ dependencies = [ name = "qbz-dsd" version = "2.1.0" dependencies = [ + "dst-decoder", "id3", "log", "qbz-disc", diff --git a/crates/qbz-disc/Cargo.toml b/crates/qbz-disc/Cargo.toml index a62802bf0..c3bc65955 100644 --- a/crates/qbz-disc/Cargo.toml +++ b/crates/qbz-disc/Cargo.toml @@ -15,6 +15,7 @@ rusqlite = { version = "0.40", features = ["bundled"] } serde = { workspace = true } serde_json = { workspace = true } dirs = "6" +encoding_rs = "0.8" [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" diff --git a/crates/qbz-disc/src/sacd.rs b/crates/qbz-disc/src/sacd.rs index 7b1d6a33a..6cd50378a 100644 --- a/crates/qbz-disc/src/sacd.rs +++ b/crates/qbz-disc/src/sacd.rs @@ -8,13 +8,18 @@ //! skip would have leaked packet descriptors into the DSD stream: audible //! noise on a path whose entire promise is bit-perfection. //! -//! WHAT IS SUPPORTED: an uncompressed (non-DST) stereo area in an image with -//! an ISO 9660 layer. Everything else is DETECTED and reported — never -//! silently approximated. +//! WHAT IS SUPPORTED: DSD64 stereo areas, either flat or DST-compressed, in +//! raw Scarlet Book dumps and ISO/UDF hybrid images. Every pointer and audio +//! extent is bounded by the image; nothing is inferred from an ISO 9660 layer. -use std::path::Path; +use std::fs::{File, Metadata}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; -use crate::iso9660::{IsoError, IsoImage, SECTOR}; +use crate::iso9660::{IsoError, SECTOR}; + +const MASTER_TOC_COPIES: [u64; 3] = [510, 520, 530]; +const MAX_AREA_TOC_SECTORS: u16 = 96; /// Sectors per second of SACD playing time. The area TOC states it as a /// max_byte_rate of 716 800 B/s, and 716800 / 2048 = 350. It is the SACD's @@ -36,20 +41,54 @@ pub const DATA_TYPE_AUDIO: u16 = 2; pub enum SacdError { #[error(transparent)] Iso(#[from] IsoError), - #[error("this image has no stereo audio area (no /2C_AUDIO/2C_AREA1.TOC)")] + #[error("this image has no Scarlet Book stereo audio area")] NoStereoArea, + #[error("this image has no Scarlet Book Master TOC")] + MissingMasterToc, #[error("the area TOC is not a Scarlet Book stereo TOC (expected TWOCHTOC)")] NotAnArea, #[error("{0} is missing from the area TOC")] MissingBlock(&'static str), - #[error("this area is DST-compressed, which is not supported")] + #[error("the legacy flat-DSD reader cannot expose compressed DST frames")] Dst, #[error("unsupported channel count: {0}")] Channels(u8), + #[error("unsupported SACD frame format: {0}")] + FrameFormat(u8), + #[error("unsupported SACD sample-frequency code: {0}")] + SampleFrequency(u8), #[error("the area declares {0} tracks, which cannot be right")] TrackCount(u8), + #[error("the Scarlet Book TOC is malformed: {0}")] + MalformedToc(&'static str), + #[error("the valid Master TOC copies disagree about the disc geometry")] + ConflictingMasterTocs, + #[error("the valid copies of an area TOC disagree about its geometry")] + ConflictingAreaTocs, #[error("sector {lsn} is malformed: {why}")] BadSector { lsn: u64, why: &'static str }, + #[error("track {track} frame at sector {lsn} is malformed: {why}")] + BadAudioFrame { + track: u8, + lsn: u64, + why: &'static str, + }, + #[error("track {track} has no frame start within 32 sectors from {lsn}")] + MissingFrameStart { track: u8, lsn: u64 }, + #[error("{0} is not a regular file")] + NotRegularFile(PathBuf), + #[error("SACD image length {0} is not a whole number of 2048-byte sectors")] + InvalidImageLength(u64), + #[error("SACD image changed while it was being read: {0}")] + ImageChangedDuringRead(PathBuf), +} + +/// How audio frames in the selected stereo area are represented. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SacdEncoding { + Dst, + Dsd3In14, + Dsd3In16, } #[derive(Debug, Clone, PartialEq)] @@ -64,6 +103,10 @@ pub struct SacdTrack { pub length_lsn: u32, pub start_secs: f64, pub duration_secs: f64, + /// Exact 75 Hz Scarlet Book timecode, without a float round-trip. + pub start_frame: u32, + pub duration_frames: u32, + pub encoding: SacdEncoding, pub title: Option, } @@ -72,6 +115,7 @@ pub struct SacdArea { pub track_start_lsn: u32, pub track_end_lsn: u32, pub channels: u8, + pub encoding: SacdEncoding, pub total_playtime_secs: f64, pub tracks: Vec, /// Album text out of the Master TOC, when it carries any. @@ -118,101 +162,553 @@ fn be32(b: &[u8], at: usize) -> u32 { u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) } -/// A time triple: minutes, seconds, frames at 75 fps. The area TOC uses it for -/// both the total playtime and every per-track time. -fn time_at(b: &[u8], at: usize) -> f64 { - b[at] as f64 * 60.0 + b[at + 1] as f64 + b[at + 2] as f64 / 75.0 +fn be16(b: &[u8], at: usize) -> u16 { + u16::from_be_bytes([b[at], b[at + 1]]) } -/// Signature sniff for a folder scan: an ISO 9660 image (PVD `CD001` at LSN -/// 16) whose Master TOC sector carries `SACDMTOC` at LSN 510, or at one of -/// its byte-identical backups (520, 530). At most four 2 KB reads at fixed -/// offsets, no directory parsing — a DVD, a data ISO or a stray file wearing -/// the extension fails here and costs the scan nothing else. -pub fn is_sacd_image(path: &Path) -> bool { - use std::io::{Read, Seek, SeekFrom}; +fn time_frames_at(b: &[u8], at: usize) -> u32 { + (b[at] as u32 * 60 + b[at + 1] as u32) * 75 + b[at + 2] as u32 +} - let Ok(mut file) = std::fs::File::open(path) else { - return false; - }; - let mut sector = [0u8; SECTOR as usize]; - let mut read_at = |lsn: u64, sector: &mut [u8]| -> bool { - file.seek(SeekFrom::Start(lsn * SECTOR)).is_ok() && file.read_exact(sector).is_ok() - }; - if !read_at(16, &mut sector) || §or[1..6] != b"CD001" { - return false; +/// A bounded reader over the 2048-byte logical sectors shared by authored +/// SACD/ISO hybrids and raw Scarlet Book dumps. No filesystem layer is +/// required: the Master TOC and every audio extent already use absolute LSNs. +struct SectorImage { + file: File, + path: PathBuf, + bytes: u64, + stamp: ImageStamp, + sectors_since_check: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ImageStamp { + bytes: u64, + modified: Option, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, + #[cfg(unix)] + change_seconds: i64, + #[cfg(unix)] + change_nanoseconds: i64, +} + +impl ImageStamp { + fn from_metadata(metadata: &Metadata) -> Self { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + + Self { + bytes: metadata.len(), + modified: metadata.modified().ok(), + #[cfg(unix)] + device: metadata.dev(), + #[cfg(unix)] + inode: metadata.ino(), + #[cfg(unix)] + change_seconds: metadata.ctime(), + #[cfg(unix)] + change_nanoseconds: metadata.ctime_nsec(), + } } - [510u64, 520, 530] +} + +impl SectorImage { + fn open(path: &Path) -> Result { + let file = File::open(path) + .map_err(|error| SacdError::Iso(IsoError::Open(path.to_path_buf(), error)))?; + let metadata = file + .metadata() + .map_err(|source| SacdError::Iso(IsoError::Read { lsn: 0, source }))?; + if !metadata.is_file() { + return Err(SacdError::NotRegularFile(path.to_path_buf())); + } + let bytes = metadata.len(); + Ok(Self { + file, + path: path.to_path_buf(), + bytes, + stamp: ImageStamp::from_metadata(&metadata), + sectors_since_check: 0, + }) + } + + fn sectors(&self) -> u64 { + self.bytes / SECTOR + } + + fn has_complete_sector(&self, lsn: u64) -> bool { + lsn < self.sectors() + } + + fn has_sectors(&self, lsn: u64, count: u64) -> bool { + lsn.checked_add(count) + .is_some_and(|end| end <= self.sectors()) + } + + fn read_sectors(&mut self, lsn: u64, count: usize) -> Result, SacdError> { + let count_u64 = + u64::try_from(count).map_err(|_| SacdError::MalformedToc("sector count overflows"))?; + let end_lsn = lsn + .checked_add(count_u64) + .ok_or(SacdError::MalformedToc("sector range overflows"))?; + if end_lsn > self.sectors() { + return Err(SacdError::Iso(IsoError::Read { + lsn, + source: std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + format!("read past end of {}", self.path.display()), + ), + })); + } + let bytes = count + .checked_mul(SECTOR as usize) + .ok_or(SacdError::MalformedToc("byte count overflows"))?; + let mut out = vec![0u8; bytes]; + self.file + .seek(SeekFrom::Start(lsn * SECTOR)) + .and_then(|_| self.file.read_exact(&mut out)) + .map_err(|source| SacdError::Iso(IsoError::Read { lsn, source }))?; + self.sectors_since_check = self.sectors_since_check.saturating_add(count_u64); + // A one-second cadence avoids a metadata syscall for every 1/75 s + // audio frame while still detecting in-place writes and path swaps + // during long playback. TOC callers also verify explicitly at end. + if self.sectors_since_check >= u64::from(SECTORS_PER_SEC) { + self.verify_unchanged()?; + self.sectors_since_check = 0; + } + Ok(out) + } + + fn verify_unchanged(&self) -> Result<(), SacdError> { + let open_stamp = self + .file + .metadata() + .ok() + .map(|metadata| ImageStamp::from_metadata(&metadata)); + let path_stamp = std::fs::metadata(&self.path) + .ok() + .map(|metadata| ImageStamp::from_metadata(&metadata)); + if open_stamp.as_ref() != Some(&self.stamp) || path_stamp.as_ref() != Some(&self.stamp) { + return Err(SacdError::ImageChangedDuringRead(self.path.clone())); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AreaSlot { + primary: u32, + backup: u32, + size: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MasterToc { + lsn: u64, + version: (u8, u8), + areas: [AreaSlot; 2], + text_area_count: u8, + text_charset: u8, +} + +fn parse_master_toc(sector: &[u8], lsn: u64, image_sectors: u64) -> Result { + if sector.len() < SECTOR as usize || §or[..8] != b"SACDMTOC" { + return Err(SacdError::MalformedToc("missing SACDMTOC signature")); + } + let version = (sector[8], sector[9]); + if version.0 != 1 || version.1 > 20 { + return Err(SacdError::MalformedToc("unsupported Master TOC version")); + } + let areas = [ + AreaSlot { + primary: be32(sector, 0x40), + backup: be32(sector, 0x44), + size: be16(sector, 0x54), + }, + AreaSlot { + primary: be32(sector, 0x48), + backup: be32(sector, 0x4c), + size: be16(sector, 0x56), + }, + ]; + for slot in areas { + if slot.primary == 0 && slot.backup == 0 && slot.size == 0 { + continue; + } + if slot.size == 0 || slot.size > MAX_AREA_TOC_SECTORS { + return Err(SacdError::MalformedToc("invalid area TOC size")); + } + if slot.primary == 0 && slot.backup == 0 { + return Err(SacdError::MalformedToc("area TOC has no readable copy")); + } + for start in [slot.primary, slot.backup] + .into_iter() + .filter(|start| *start != 0) + { + if u64::from(start) + .checked_add(u64::from(slot.size)) + .is_none_or(|end| end > image_sectors) + { + return Err(SacdError::MalformedToc("area TOC points outside the image")); + } + } + } + if areas.iter().all(|slot| slot.size == 0) { + return Err(SacdError::NoStereoArea); + } + Ok(MasterToc { + lsn, + version, + areas, + text_area_count: sector[0x80], + // First of the eight locale entries at 0x88: language[2], charset, + // reserved. QBZ deliberately selects the first text area, as the + // reference reader does. + text_charset: sector[0x8a], + }) +} + +fn select_master_toc(image: &mut SectorImage) -> Result, SacdError> { + let mut valid = Vec::new(); + let mut signed_error = None; + for lsn in MASTER_TOC_COPIES { + if !image.has_complete_sector(lsn) { + continue; + } + let first = image.read_sectors(lsn, 1)?; + if &first[..8] != b"SACDMTOC" { + continue; + } + if !image.has_sectors(lsn, 10) { + signed_error.get_or_insert(SacdError::MalformedToc("Master TOC copy is truncated")); + continue; + } + let sector = image.read_sectors(lsn, 10)?; + match parse_master_toc(§or, lsn, image.sectors()) { + Ok(master) => valid.push(master), + Err(error) => { + signed_error.get_or_insert(error); + } + }; + } + let Some(selected) = valid.first().copied() else { + return match signed_error { + Some(error) => Err(error), + None => Ok(None), + }; + }; + if valid + .iter() + .skip(1) + .any(|copy| copy.version != selected.version || copy.areas != selected.areas) + { + return Err(SacdError::ConflictingMasterTocs); + } + Ok(Some(selected)) +} + +/// Result of the cheap scan-time signature and Master TOC validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SacdSniff { + NotSacd, + Sacd, +} + +/// Signature sniff for a folder scan. Scarlet Book identity is the validated +/// Master TOC, not the optional ISO 9660 compatibility layer. +pub fn sniff_sacd_image(path: &Path) -> Result { + let mut image = SectorImage::open(path)?; + if !MASTER_TOC_COPIES .into_iter() - .any(|lsn| read_at(lsn, &mut sector) && §or[0..8] == b"SACDMTOC") + .any(|lsn| image.has_complete_sector(lsn)) + { + return Ok(SacdSniff::NotSacd); + } + if image.bytes % SECTOR != 0 { + // Preserve the useful distinction between an arbitrary odd-sized file + // and a signed SACD image with a truncated tail. Do this before the + // full Master parse so another malformed field cannot hide truncation. + for lsn in MASTER_TOC_COPIES { + if image.has_complete_sector(lsn) { + let first = image.read_sectors(lsn, 1)?; + if &first[..8] == b"SACDMTOC" { + image.verify_unchanged()?; + return Err(SacdError::InvalidImageLength(image.bytes)); + } + } + } + image.verify_unchanged()?; + return Ok(SacdSniff::NotSacd); + } + let result = match select_master_toc(&mut image)? { + Some(_) => SacdSniff::Sacd, + None => SacdSniff::NotSacd, + }; + image.verify_unchanged()?; + Ok(result) +} + +/// Compatibility convenience for callers that only need a boolean. +pub fn is_sacd_image(path: &Path) -> bool { + matches!(sniff_sacd_image(path), Ok(SacdSniff::Sacd)) } /// Read the stereo area's table of contents out of a disc image. pub fn read_area(path: &Path) -> Result { - let mut iso = IsoImage::open(path)?; - let toc_entry = iso - .find("/2C_AUDIO/2C_AREA1.TOC")? - .ok_or(SacdError::NoStereoArea)?; - // 9 sectors; read them all, the blocks live at fixed sector offsets. - let toc = iso.read_sectors(toc_entry.lsn as u64, 9)?; - - if &toc[0..8] != b"TWOCHTOC" { - return Err(SacdError::NotAnArea); + let mut image = SectorImage::open(path)?; + if image.bytes % SECTOR != 0 { + return Err(SacdError::InvalidImageLength(image.bytes)); } + let master = select_master_toc(&mut image)?.ok_or(SacdError::MissingMasterToc)?; + + let mut stereo = None; + let mut first_area_error = None; + for slot in master.areas.into_iter().filter(|slot| slot.size != 0) { + match read_area_slot(&mut image, slot) { + Ok(toc) if &toc[..8] == b"TWOCHTOC" => { + if stereo.replace(toc).is_some() { + return Err(SacdError::MalformedToc( + "disc declares more than one stereo area", + )); + } + } + Ok(_) => {} + Err(error) => { + first_area_error.get_or_insert(error); + } + } + } + let toc = match stereo { + Some(toc) => toc, + None => return Err(first_area_error.unwrap_or(SacdError::NoStereoArea)), + }; + let area = parse_stereo_area(&mut image, master, &toc)?; + image.verify_unchanged()?; + Ok(area) +} + +fn read_area_slot(image: &mut SectorImage, slot: AreaSlot) -> Result, SacdError> { + let mut first_error = None; + let mut valid: Vec<(u32, Vec, Vec)> = Vec::new(); + for start in [slot.primary, slot.backup] + .into_iter() + .filter(|start| *start != 0) + { + let attempt = image.read_sectors(u64::from(start), usize::from(slot.size)); + match attempt { + Ok(mut toc) + if toc.len() >= SECTOR as usize + && (&toc[..8] == b"TWOCHTOC" || &toc[..8] == b"MULCHTOC") => + { + let internal_size = be16(&toc, 10); + if internal_size == 0 || internal_size > slot.size { + first_error.get_or_insert(SacdError::MalformedToc( + "area TOC internal size exceeds its Master TOC extent", + )); + continue; + } + toc.truncate(usize::from(internal_size) * SECTOR as usize); + match area_geometry_key(&toc, image.sectors()) { + Ok(key) => valid.push((start, toc, key)), + Err(error) => { + first_error.get_or_insert(error); + } + } + } + Ok(_) => { + first_error.get_or_insert(SacdError::NotAnArea); + } + Err(error) => { + first_error.get_or_insert(error); + } + } + } + let Some((_, selected, selected_key)) = valid.first() else { + return Err(first_error.unwrap_or(SacdError::NotAnArea)); + }; + if valid.iter().skip(1).any(|(_, _, key)| key != selected_key) { + return Err(SacdError::ConflictingAreaTocs); + } + Ok(selected.clone()) +} + +fn area_geometry_key(toc: &[u8], image_sectors: u64) -> Result, SacdError> { + let version = (toc[8], toc[9]); + if version.0 != 1 || version.1 > 20 { + return Err(SacdError::MalformedToc("unsupported area TOC version")); + } + if toc[0x14] != 4 { + return Err(SacdError::SampleFrequency(toc[0x14])); + } + if !matches!(toc[0x15] & 0x0f, 0 | 2 | 3) { + return Err(SacdError::FrameFormat(toc[0x15] & 0x0f)); + } + if toc[0x45] == 0 { + return Err(SacdError::TrackCount(0)); + } + let start = be32(toc, 0x48); + let end = be32(toc, 0x4c); + if start > end || u64::from(end) >= image_sectors { + return Err(SacdError::MalformedToc( + "audio area points outside the image", + )); + } + let trl1 = find_toc_block(toc, b"SACDTRL1", None).ok_or(SacdError::MissingBlock("SACDTRL1"))?; + let trl2 = find_toc_block(toc, b"SACDTRL2", None).ok_or(SacdError::MissingBlock("SACDTRL2"))?; + let count = usize::from(toc[0x45]); + const ARR2: usize = 8 + 255 * 4; + let mut key = Vec::with_capacity(64 + count * 16); + key.extend_from_slice(&toc[..12]); + key.extend_from_slice(&toc[0x10..0x16]); + key.extend_from_slice(&toc[0x20..0x22]); + key.extend_from_slice(&toc[0x40..0x46]); + key.extend_from_slice(&toc[0x48..0x51]); + key.extend_from_slice(&toc[0x80..0x86]); + key.extend_from_slice(&trl1[8..8 + count * 4]); + key.extend_from_slice(&trl1[ARR2..ARR2 + count * 4]); + key.extend_from_slice(&trl2[8..8 + count * 4]); + key.extend_from_slice(&trl2[ARR2..ARR2 + count * 4]); + let mut previous = None; + for i in 0..count { + let track_start = be32(trl1, 8 + i * 4); + let length = be32(trl1, ARR2 + i * 4); + let track_end = u64::from(track_start) + .checked_add(u64::from(length)) + .ok_or(SacdError::MalformedToc("track sector range overflows"))?; + if length == 0 + || track_start < start + || track_end > u64::from(end) + 1 + || previous.is_some_and(|prior| track_start <= prior) + || trl2[8 + i * 4 + 1] >= 60 + || trl2[8 + i * 4 + 2] >= 75 + || trl2[ARR2 + i * 4 + 1] >= 60 + || trl2[ARR2 + i * 4 + 2] >= 75 + { + return Err(SacdError::MalformedToc("track table is invalid")); + } + previous = Some(track_start); + } + Ok(key) +} + +fn parse_stereo_area( + image: &mut SectorImage, + master: MasterToc, + toc: &[u8], +) -> Result { let channels = toc[0x20]; - if channels != 2 { + if channels != 2 || toc[0x21] & 0x1f != 0 { return Err(SacdError::Channels(channels)); } - // A u8 cannot exceed 255, so the only impossible value is zero — an area - // that declares no tracks is a TOC we have misread, not an empty disc. + if toc[0x14] != 4 { + return Err(SacdError::SampleFrequency(toc[0x14])); + } + let encoding = match toc[0x15] & 0x0f { + 0 => SacdEncoding::Dst, + 2 => SacdEncoding::Dsd3In14, + 3 => SacdEncoding::Dsd3In16, + other => return Err(SacdError::FrameFormat(other)), + }; let track_count = toc[0x45]; if track_count == 0 { return Err(SacdError::TrackCount(track_count)); } - let total_playtime_secs = time_at(&toc, 0x40); - let track_start_lsn = be32(&toc, 0x48); - let track_end_lsn = be32(&toc, 0x4C); + let total_frames = time_frames_at(toc, 0x40); + if toc[0x41] >= 60 || toc[0x42] >= 75 { + return Err(SacdError::MalformedToc("invalid area playtime")); + } + let track_start_lsn = be32(toc, 0x48); + let track_end_lsn = be32(toc, 0x4c); + if track_start_lsn > track_end_lsn || u64::from(track_end_lsn) >= image.sectors() { + return Err(SacdError::MalformedToc( + "audio area points outside the image", + )); + } - // Sector 1 = SACDTRL1 (starts + lengths), sector 2 = SACDTRL2 (times). - let trl1 = &toc[SECTOR as usize..]; - let trl2 = &toc[2 * SECTOR as usize..]; - if &trl1[0..8] != b"SACDTRL1" { - return Err(SacdError::MissingBlock("SACDTRL1")); - } - if &trl2[0..8] != b"SACDTRL2" { - return Err(SacdError::MissingBlock("SACDTRL2")); - } - // Both blocks are two parallel arrays of 255 u32 big-endian entries: the - // second starts at 8 + 255*4. + let trl1 = find_toc_block(toc, b"SACDTRL1", None).ok_or(SacdError::MissingBlock("SACDTRL1"))?; + let trl2 = find_toc_block(toc, b"SACDTRL2", None).ok_or(SacdError::MissingBlock("SACDTRL2"))?; const ARR2: usize = 8 + 255 * 4; - let titles = read_titles(&toc, track_count as usize); + let text_offset = be16(toc, 0x80); + let text_charset = toc[0x5a]; + let titles = find_toc_block(toc, b"SACDTTxt", (text_offset != 0).then_some(text_offset)) + .map(|block| read_titles(block, track_count as usize, text_charset)) + .unwrap_or_else(|| vec![None; track_count as usize]); let mut tracks = Vec::with_capacity(track_count as usize); + let mut previous_start = None; for i in 0..track_count as usize { + let start_lsn = be32(trl1, 8 + i * 4); + let length_lsn = be32(trl1, ARR2 + i * 4); + let start_frame = time_frames_at(trl2, 8 + i * 4); + let duration_frame = time_frames_at(trl2, ARR2 + i * 4); + if trl2[8 + i * 4 + 1] >= 60 + || trl2[8 + i * 4 + 2] >= 75 + || trl2[ARR2 + i * 4 + 1] >= 60 + || trl2[ARR2 + i * 4 + 2] >= 75 + { + return Err(SacdError::MalformedToc("invalid track timecode")); + } + let end = u64::from(start_lsn) + .checked_add(u64::from(length_lsn)) + .ok_or(SacdError::MalformedToc("track sector range overflows"))?; + if length_lsn == 0 + || start_lsn < track_start_lsn + || u64::from(start_lsn) > u64::from(track_end_lsn) + || end > u64::from(track_end_lsn) + 1 + || previous_start.is_some_and(|previous| start_lsn <= previous) + { + return Err(SacdError::MalformedToc("track sector range is invalid")); + } + previous_start = Some(start_lsn); tracks.push(SacdTrack { number: (i + 1) as u8, - start_lsn: be32(trl1, 8 + i * 4), - length_lsn: be32(trl1, ARR2 + i * 4), - start_secs: time_at(trl2, 8 + i * 4), - duration_secs: time_at(trl2, ARR2 + i * 4), + start_lsn, + length_lsn, + start_secs: start_frame as f64 / 75.0, + duration_secs: duration_frame as f64 / 75.0, + start_frame, + duration_frames: duration_frame, + encoding, title: titles.get(i).cloned().flatten(), }); } - let (album, artist) = read_master_text(&mut iso).unwrap_or((None, None)); - + let (album, artist) = read_master_text(image, master).unwrap_or((None, None)); Ok(SacdArea { track_start_lsn, track_end_lsn, channels, - total_playtime_secs, + encoding, + total_playtime_secs: total_frames as f64 / 75.0, tracks, album, artist, }) } +fn find_toc_block<'a>( + toc: &'a [u8], + signature: &[u8; 8], + declared: Option, +) -> Option<&'a [u8]> { + let at_sector = |relative: usize| -> Option<&'a [u8]> { + let at = relative.checked_mul(SECTOR as usize)?; + let block = toc.get(at..)?; + if block.len() < SECTOR as usize { + return None; + } + (&block[..8] == signature).then_some(block) + }; + if let Some(relative) = declared { + return at_sector(usize::from(relative)); + } + (0..toc.len() / SECTOR as usize).find_map(at_sector) +} + /// Album title and artist out of the Master TOC's text sector. /// /// A SACD names itself, which is why this feature needs no network the way a @@ -226,14 +722,14 @@ pub fn read_area(path: &Path) -> Result { /// /// Everything here is optional: an image whose Master TOC is unreadable or /// carries no text still plays, it just falls back to the file name. -fn read_master_text(iso: &mut IsoImage) -> Option<(Option, Option)> { - // By LSN, not by name: the Master TOC is at a fixed place, and an image - // whose directory is odd should still give up its title. - let master = iso.read_sectors(510, 2).ok()?; - if &master[0..8] != b"SACDMTOC" { +fn read_master_text( + image: &mut SectorImage, + master: MasterToc, +) -> Option<(Option, Option)> { + if master.text_area_count == 0 { return None; } - let text = &master[SECTOR as usize..]; + let text = image.read_sectors(master.lsn + 1, 1).ok()?; if &text[0..8] != b"SACDText" { return None; } @@ -244,28 +740,15 @@ fn read_master_text(iso: &mut IsoImage) -> Option<(Option, Option Option { let end = text[off..].iter().position(|b| *b == 0)? + off; - let s = String::from_utf8_lossy(&text[off..end]).trim().to_string(); - (!s.is_empty()).then_some(s) + decode_sacd_text(&text[off..end], master.text_charset, "Master TOC text") }; - Some(( - ptr(0x10).and_then(string_at), - ptr(0x12).and_then(string_at), - )) + Some((ptr(0x10).and_then(string_at), ptr(0x12).and_then(string_at))) } -/// Track titles from the SACDTTxt block (sector 5 of the area TOC). -/// -/// The pointer table holds one big-endian u16 per track, and each value is an -/// offset RELATIVE TO THE START OF THE BLOCK — not to the file, and not to the -/// sector the text happens to land in. Getting that wrong yields plausible -/// garbage rather than an error, so titles are treated as optional throughout: -/// a track with no readable name keeps its number. -fn read_titles(toc: &[u8], count: usize) -> Vec> { - let base = 5 * SECTOR as usize; - let block = match toc.get(base..) { - Some(b) if b.len() > 8 && &b[0..8] == b"SACDTTxt" => b, - _ => return vec![None; count], - }; +/// Track titles from one validated `SACDTTxt` sector. The block's pointer +/// table is relative to the block, and each pointed record can contain several +/// typed strings; type 1 is the title. +fn read_titles(block: &[u8], count: usize, charset: u8) -> Vec> { let mut out = Vec::with_capacity(count); for i in 0..count { let at = 8 + i * 2; @@ -276,29 +759,71 @@ fn read_titles(toc: &[u8], count: usize) -> Vec> { continue; } }; - out.push(text_at(block, ptr)); + out.push(track_title_at(block, ptr, charset, i + 1)); } out } -/// One entry of the text payload. The layout carries a small header before the -/// string, so this scans for the first printable run rather than assuming an -/// offset that was never measured — an honest approximation, and it degrades -/// to `None` instead of to nonsense. -fn text_at(block: &[u8], ptr: usize) -> Option { - let slice = block.get(ptr..(ptr + 256).min(block.len()))?; - let start = slice.iter().position(|b| (0x20..0x7f).contains(b))?; - let end = slice[start..] - .iter() - .position(|b| *b == 0) - .map(|e| start + e) - .unwrap_or(slice.len()); - let s = String::from_utf8_lossy(&slice[start..end]).trim().to_string(); - if s.is_empty() { - None - } else { - Some(s) +fn track_title_at(block: &[u8], ptr: usize, charset: u8, track: usize) -> Option { + let amount = *block.get(ptr)? as usize; + let mut at = ptr.checked_add(4)?; + for _ in 0..amount { + let kind = *block.get(at)?; + at = at.checked_add(2)?; + let tail = block.get(at..)?; + let len = tail.iter().position(|byte| *byte == 0)?; + if kind == 1 { + return decode_sacd_text(&tail[..len], charset, &format!("track {track} title")); + } + at = at.checked_add(len)?; + while block.get(at).is_some_and(|byte| *byte == 0) { + at += 1; + } } + None +} + +/// Convert one bounded Scarlet Book string to UTF-8. Invalid input degrades +/// only that optional field: geometry and playback remain usable, but no +/// replacement characters are invented and a warning records the fallback. +fn decode_sacd_text(raw: &[u8], charset: u8, field: &str) -> Option { + use encoding_rs::{BIG5, EUC_KR, GBK, SHIFT_JIS}; + + let code = charset & 0x07; + let decoded = match code { + // Unknown is not permission to guess. ISO 646 is 7-bit; reject a + // high byte rather than laundering it through UTF-8 replacement. + 0 => None, + 1 => raw + .iter() + .all(|byte| byte.is_ascii()) + .then(|| String::from_utf8(raw.to_vec()).expect("ASCII is UTF-8")), + // ISO-8859-1 maps every byte directly to the same Unicode scalar. + // Code 7 permits escape sequences; the common no-escape form is the + // same mapping, while an actual ESC requires state we do not guess. + 2 => Some(raw.iter().map(|byte| char::from(*byte)).collect()), + 3 => SHIFT_JIS + .decode_without_bom_handling_and_without_replacement(raw) + .map(|value| value.into_owned()), + 4 => EUC_KR + .decode_without_bom_handling_and_without_replacement(raw) + .map(|value| value.into_owned()), + 5 => GBK + .decode_without_bom_handling_and_without_replacement(raw) + .map(|value| value.into_owned()), + 6 => BIG5 + .decode_without_bom_handling_and_without_replacement(raw) + .map(|value| value.into_owned()), + 7 if !raw.contains(&0x1b) => Some(raw.iter().map(|byte| char::from(*byte)).collect()), + 7 => None, + _ => unreachable!("charset code is masked to three bits"), + }; + let Some(text) = decoded else { + log::warn!("[sacd] ignoring {field}: unknown charset {code} or invalid byte sequence"); + return None; + }; + let text = text.trim().to_string(); + (!text.is_empty()).then_some(text) } // --------------------------------------------------------------------------- @@ -313,6 +838,16 @@ pub struct Packet { pub len: usize, /// Offset of the payload within the 2048-byte sector. pub at: usize, + /// Sector header bit. DST sectors carry four-byte frame-info records; + /// uncompressed DSD sectors carry three-byte records. + pub dst_encoded: bool, + /// For a DST frame-start packet, the declared number of sectors occupied + /// by this frame. Absent for continuation and uncompressed packets. + pub frame_sector_count: Option, + /// Absolute area timecode carried by the matching frame-info record. + pub frame_timecode: Option, + /// Channel count encoded in a DST frame-info record. + pub frame_channels: Option, } /// Decode one sector's framing. @@ -324,8 +859,7 @@ pub struct Packet { /// bit 15 frame_start /// bits 14-11 data_type /// bits 10-0 length IN BYTES <- ELEVEN bits, not twelve -/// then `frame_info_count` * 3 bytes -/// header_len = 1 + packet_count*2 + frame_info_count*3 +/// then `frame_info_count` * (DST ? 4 : 3) bytes /// /// The eleven-bit length is the correction that matters. The widely-copied /// layout gives the length twelve bits, and the very first descriptor on both @@ -341,9 +875,12 @@ pub fn parse_sector(sector: &[u8], lsn: u64) -> Result, SacdError> { }); } let b0 = sector[0]; + let dst_encoded = b0 & 0x01 != 0; let packet_count = ((b0 >> 5) & 0x07) as usize; let frame_info_count = ((b0 >> 2) & 0x07) as usize; - let header_len = 1 + packet_count * 2 + frame_info_count * 3; + let frame_info_size = if dst_encoded { 4 } else { 3 }; + let frame_info_at = 1 + packet_count * 2; + let header_len = frame_info_at + frame_info_count * frame_info_size; if packet_count == 0 || header_len > sector.len() { return Err(SacdError::BadSector { lsn, @@ -354,29 +891,381 @@ pub fn parse_sector(sector: &[u8], lsn: u64) -> Result, SacdError> { let mut packets = Vec::with_capacity(packet_count); let mut at = header_len; let mut total = header_len; + let mut frame_info = 0usize; for k in 0..packet_count { let w = u16::from_be_bytes([sector[1 + 2 * k], sector[2 + 2 * k]]); let len = (w & 0x07FF) as usize; + let frame_start = w & 0x8000 != 0; + let data_type = (w >> 11) & 0x07; + // Lead-out sectors on real discs mark their final padding packet as a + // frame start and pair it with an all-0xff sentinel frame-info row. + // The row still counts toward `frame_info_count`, but it has no audio + // semantics and its sentinel timecode must not be validated. + let info = if frame_start { + let info = frame_info_at + frame_info * frame_info_size; + frame_info += 1; + Some(info) + } else { + None + }; + let (frame_sector_count, frame_timecode, frame_channels) = + if let Some(info) = info.filter(|_| data_type == DATA_TYPE_AUDIO) { + let time = sector.get(info..info + 3).ok_or(SacdError::BadSector { + lsn, + why: "frame-info table is truncated", + })?; + if time[1] >= 60 || time[2] >= 75 { + return Err(SacdError::BadSector { + lsn, + why: "frame-info timecode is invalid", + }); + } + let timecode = (time[0] as u32 * 60 + time[1] as u32) * 75 + time[2] as u32; + if dst_encoded { + let flags = sector[info + 3]; + let channels = match (flags & 0x02 != 0, flags & 0x01 != 0) { + (true, false) => 6, + (false, true) => 5, + _ => 2, + }; + (Some((flags >> 2) & 0x1f), Some(timecode), Some(channels)) + } else { + (None, Some(timecode), Some(2)) + } + } else { + (None, None, None) + }; packets.push(Packet { - frame_start: w & 0x8000 != 0, - data_type: (w >> 11) & 0x0F, + frame_start, + data_type, len, at, + dst_encoded, + frame_sector_count, + frame_timecode, + frame_channels, }); at += len; total += len; } - // The sum rule. A sector that fails it has been decoded WRONG, and the - // right answer is to stop rather than to emit whatever bytes are there. - if total != SECTOR as usize { + // Flat DSD fills the sector exactly. Real DST authoring also permits an + // undeclared zero tail after the last packet (1..6 bytes commonly, and a + // larger zero tail when a compressed frame ends early). It is alignment, + // not audio: accept only all-zero DST tails and never publish them. + if total > SECTOR as usize + || (total < SECTOR as usize + && (!dst_encoded || sector[total..].iter().any(|byte| *byte != 0))) + { return Err(SacdError::BadSector { lsn, - why: "packet lengths do not fill the sector", + why: "packet lengths do not account for the sector", + }); + } + if frame_info != frame_info_count { + return Err(SacdError::BadSector { + lsn, + why: "frame-info count does not match frame starts", }); } Ok(packets) } +pub const MAX_DST_FRAME: usize = 64 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SacdFrameKind { + Dsd, + Dst, +} + +/// One complete, container-validated 1/75-second audio unit. DST remains +/// compressed here; decoding belongs to qbz-dsd. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SacdAudioFrame { + pub kind: SacdFrameKind, + pub payload: Vec, + pub start_lsn: u64, + pub timecode_frame: u32, +} + +struct BuildingFrame { + frame: SacdAudioFrame, + dst_packets_left: Option, +} + +/// Assemble exact Scarlet Book frames inside one track window. The reader +/// uses TRL2 timecodes as the publication boundary, so a sector shared by two +/// tracks neither duplicates nor loses its boundary frame. +pub struct SacdFrameReader { + image: SectorImage, + track: u8, + encoding: SacdEncoding, + start_lsn: u64, + next_lsn: u64, + end_lsn: u64, + track_start_frame: u32, + track_end_frame: u32, + target_frame: u32, + emitted: u32, + current: Option, + ready: std::collections::VecDeque, + search_started_at: u64, + searched_sectors: u8, + saw_frame_start: bool, +} + +impl SacdFrameReader { + pub fn open(path: &Path, track: &SacdTrack) -> Result { + let image = SectorImage::open(path)?; + let start_lsn = u64::from(track.start_lsn); + let end_lsn = start_lsn + .checked_add(u64::from(track.length_lsn)) + .ok_or(SacdError::MalformedToc("track sector range overflows"))?; + if end_lsn > image.sectors() { + return Err(SacdError::MalformedToc("track points outside the image")); + } + let track_end_frame = track + .start_frame + .checked_add(track.duration_frames) + .ok_or(SacdError::MalformedToc("track timecode overflows"))?; + Ok(Self { + image, + track: track.number, + encoding: track.encoding, + start_lsn, + next_lsn: start_lsn, + end_lsn, + track_start_frame: track.start_frame, + track_end_frame, + target_frame: track.start_frame, + emitted: 0, + current: None, + ready: std::collections::VecDeque::new(), + search_started_at: start_lsn, + searched_sectors: 0, + saw_frame_start: false, + }) + } + + pub fn finished(&self) -> bool { + self.target_frame.saturating_add(self.emitted) >= self.track_end_frame + } + + pub fn seek_to_fraction( + &mut self, + offset_units: u64, + total_units: u64, + ) -> Result<(), SacdError> { + let duration = self.track_end_frame - self.track_start_frame; + let frame_offset = if total_units == 0 { + 0 + } else { + ((u128::from(duration) * u128::from(offset_units.min(total_units))) + / u128::from(total_units)) as u32 + }; + self.target_frame = self.track_start_frame.saturating_add(frame_offset); + self.emitted = 0; + self.current = None; + self.ready.clear(); + if self.target_frame >= self.track_end_frame { + self.next_lsn = self.end_lsn; + } else { + // DST is variable-rate: a proportional byte/sector estimate can + // be thousands of sectors past the requested time even though it + // looks convincing on constant-rate DSD. Binary-search the + // monotonic on-disc frame timecodes instead. A probe looks ahead + // at most the format's 31-sector DST-frame bound; the final + // 32-sector backoff guarantees the requested frame is reacquired + // whole rather than entered through a continuation packet. + let mut low = self.start_lsn; + let mut high = self.end_lsn; + while high.saturating_sub(low) > 1 { + let middle = low + (high - low) / 2; + match self.frame_at_or_after(middle)? { + Some((_, timecode)) if timecode < self.target_frame => low = middle + 1, + _ => high = middle, + } + } + self.next_lsn = low.saturating_sub(32).max(self.start_lsn); + } + self.search_started_at = self.next_lsn; + self.searched_sectors = 0; + self.saw_frame_start = false; + Ok(()) + } + + fn frame_at_or_after(&mut self, start_lsn: u64) -> Result, SacdError> { + let stop = start_lsn.saturating_add(32).min(self.end_lsn); + for lsn in start_lsn..stop { + let raw = self.image.read_sectors(lsn, 1)?; + let packets = parse_sector(&raw, lsn)?; + let sector_dst = packets.first().is_some_and(|packet| packet.dst_encoded); + if sector_dst != (self.encoding == SacdEncoding::Dst) { + return Err(SacdError::BadSector { + lsn, + why: "sector encoding disagrees with the area TOC", + }); + } + if let Some(packet) = packets + .iter() + .find(|packet| packet.data_type == DATA_TYPE_AUDIO && packet.frame_start) + { + if packet.frame_channels != Some(2) { + return Err(self.bad_frame(lsn, "frame is not stereo")); + } + return packet + .frame_timecode + .map(|timecode| Some((lsn, timecode))) + .ok_or(SacdError::BadSector { + lsn, + why: "frame start has no timecode", + }); + } + } + Ok(None) + } + + pub fn next_frame(&mut self) -> Result, SacdError> { + if self.finished() { + self.image.verify_unchanged()?; + return Ok(None); + } + loop { + if let Some(frame) = self.ready.pop_front() { + if frame.timecode_frame < self.target_frame { + continue; + } + let expected = self.target_frame.saturating_add(self.emitted); + if frame.timecode_frame != expected { + return Err(self.bad_frame( + frame.start_lsn, + "frame timecode is discontinuous or seek overshot its target", + )); + } + self.emitted += 1; + return Ok(Some(frame)); + } + if self.next_lsn >= self.end_lsn { + self.finish_current()?; + if self.ready.is_empty() { + return Err( + self.bad_frame(self.end_lsn, "track ended before its TRL2 duration") + ); + } + continue; + } + self.read_next_sector()?; + } + } + + fn read_next_sector(&mut self) -> Result<(), SacdError> { + let lsn = self.next_lsn; + let raw = self.image.read_sectors(lsn, 1)?; + self.next_lsn += 1; + let packets = parse_sector(&raw, lsn)?; + let sector_dst = packets.first().is_some_and(|packet| packet.dst_encoded); + if sector_dst != (self.encoding == SacdEncoding::Dst) { + return Err(SacdError::BadSector { + lsn, + why: "sector encoding disagrees with the area TOC", + }); + } + self.searched_sectors = self.searched_sectors.saturating_add(1); + for packet in packets { + if packet.data_type != DATA_TYPE_AUDIO { + continue; + } + if packet.frame_start { + self.saw_frame_start = true; + self.finish_current()?; + let timecode_frame = packet.frame_timecode.ok_or(SacdError::BadSector { + lsn, + why: "frame start has no timecode", + })?; + if packet.frame_channels != Some(2) { + return Err(self.bad_frame(lsn, "frame is not stereo")); + } + let dst_packets_left = if sector_dst { + match packet.frame_sector_count { + Some(1..=31) => packet.frame_sector_count, + _ => return Err(self.bad_frame(lsn, "invalid DST sector count")), + } + } else { + None + }; + self.current = Some(BuildingFrame { + frame: SacdAudioFrame { + kind: if sector_dst { + SacdFrameKind::Dst + } else { + SacdFrameKind::Dsd + }, + payload: Vec::with_capacity(if sector_dst { + MAX_DST_FRAME.min(16 * 1024) + } else { + DSD64_STEREO_FRAME as usize + }), + start_lsn: lsn, + timecode_frame, + }, + dst_packets_left, + }); + } + let Some(building) = self.current.as_mut() else { + continue; + }; + if let Some(left) = building.dst_packets_left.as_mut() { + if *left == 0 { + return Err(self.bad_frame(lsn, "DST sector count ended before the frame")); + } + *left -= 1; + } + let end = packet.at + packet.len; + building + .frame + .payload + .extend_from_slice(&raw[packet.at..end]); + if building.frame.payload.len() > MAX_DST_FRAME { + return Err(self.bad_frame(lsn, "audio frame exceeds 64 KiB")); + } + } + if !self.saw_frame_start && self.searched_sectors >= 32 { + return Err(SacdError::MissingFrameStart { + track: self.track, + lsn: self.search_started_at, + }); + } + Ok(()) + } + + fn finish_current(&mut self) -> Result<(), SacdError> { + let Some(building) = self.current.take() else { + return Ok(()); + }; + let valid = match building.frame.kind { + SacdFrameKind::Dsd => building.frame.payload.len() == DSD64_STEREO_FRAME as usize, + SacdFrameKind::Dst => building.dst_packets_left == Some(0), + }; + if !valid { + return Err(self.bad_frame(building.frame.start_lsn, "frame closed at the wrong size")); + } + if building.frame.timecode_frame >= self.target_frame + && building.frame.timecode_frame < self.track_end_frame + { + self.ready.push_back(building.frame); + } + Ok(()) + } + + fn bad_frame(&self, lsn: u64, why: &'static str) -> SacdError { + SacdError::BadAudioFrame { + track: self.track, + lsn, + why, + } + } +} + /// Streams the raw DSD of one track out of an image. /// Where the first DSD frame is taken to begin. /// @@ -402,7 +1291,7 @@ pub enum FrameSync { } pub struct SacdTrackReader { - iso: IsoImage, + image: SectorImage, start_lsn: u64, next_lsn: u64, end_lsn: u64, @@ -411,6 +1300,7 @@ pub struct SacdTrackReader { /// construction — `FromPayloadStart` simply starts out true — so the mode /// itself is not kept around to be read twice. synced: bool, + expected_dst: bool, } impl SacdTrackReader { @@ -420,14 +1310,22 @@ impl SacdTrackReader { pub fn open_with(path: &Path, track: &SacdTrack, sync: FrameSync) -> Result { let start_lsn = track.start_lsn as u64; + let image = SectorImage::open(path)?; + let end_lsn = start_lsn + .checked_add(u64::from(track.length_lsn)) + .ok_or(SacdError::MalformedToc("track sector range overflows"))?; + if end_lsn > image.sectors() { + return Err(SacdError::MalformedToc("track points outside the image")); + } Ok(Self { - iso: IsoImage::open(path)?, + image, start_lsn, next_lsn: start_lsn, // `length_lsn` is how many sectors to read, including the shared // boundary sector — the disc's own accounting, not ours. - end_lsn: track.start_lsn as u64 + track.length_lsn as u64, + end_lsn, synced: matches!(sync, FrameSync::FromPayloadStart), + expected_dst: track.encoding == SacdEncoding::Dst, }) } @@ -466,11 +1364,24 @@ impl SacdTrackReader { return Ok(0); } let want = sectors.min((self.end_lsn - self.next_lsn) as usize); - let raw = self.iso.read_sectors(self.next_lsn, want)?; + let raw = self.image.read_sectors(self.next_lsn, want)?; for s in 0..want { let lsn = self.next_lsn + s as u64; let sector = &raw[s * SECTOR as usize..(s + 1) * SECTOR as usize]; - for p in parse_sector(sector, lsn)? { + let packets = parse_sector(sector, lsn)?; + if packets + .first() + .is_some_and(|packet| packet.dst_encoded != self.expected_dst) + { + return Err(SacdError::BadSector { + lsn, + why: "sector encoding disagrees with the area TOC", + }); + } + if self.expected_dst { + return Err(SacdError::Dst); + } + for p in packets { if p.data_type != DATA_TYPE_AUDIO { continue; } @@ -488,6 +1399,9 @@ impl SacdTrackReader { } } self.next_lsn += want as u64; + if self.finished() { + self.image.verify_unchanged()?; + } Ok(out.len()) } } @@ -523,7 +1437,10 @@ mod tests { // LSN 1254: 0x40 -> header 5, then (type 3, 27) + (type 2, 2016) let p = parse_sector(§or(0x40, &[0x181B, 0x17E0]), 1254).unwrap(); assert_eq!((p[0].len, p[0].at), (27, 5)); - assert!(!p[0].frame_start && !p[1].frame_start, "0x40 declares no frame info"); + assert!( + !p[0].frame_start && !p[1].frame_start, + "0x40 declares no frame info" + ); assert_eq!(5 + 27 + 2016, 2048); // LSN 1257: 0x64 -> header 10, three packets, the last starting a frame @@ -554,6 +1471,205 @@ mod tests { } } + #[test] + fn a_dst_sector_uses_four_byte_frame_info_and_exposes_its_boundary() { + let mut raw = sector(0x25, &[0x97f9]); + raw[3..7].copy_from_slice(&[1, 2, 3, 0x04]); + let packets = parse_sector(&raw, 77).unwrap(); + assert_eq!(packets.len(), 1); + assert_eq!(packets[0].at, 7); + assert_eq!(packets[0].len, 2041); + assert!(packets[0].dst_encoded); + assert_eq!(packets[0].frame_sector_count, Some(1)); + assert_eq!(packets[0].frame_timecode, Some((60 + 2) * 75 + 3)); + assert_eq!(packets[0].frame_channels, Some(2)); + } + + #[test] + fn frame_info_count_must_equal_the_number_of_starts() { + let raw = sector(0x25, &[0x17f9]); + assert!(matches!( + parse_sector(&raw, 78), + Err(SacdError::BadSector { .. }) + )); + } + + #[test] + fn a_lead_out_padding_start_consumes_but_does_not_validate_its_sentinel_info() { + // Exact shape of the last Bowie and Ritchie area sector: a normal + // supplementary packet followed by padding whose frame-start bit is + // paired with an all-0xff sentinel rather than an audio timecode. + let mut raw = sector(0x44, &[0x1818, 0xbfe0]); + raw[5..8].fill(0xff); + let packets = parse_sector(&raw, 959_402).unwrap(); + assert_eq!(packets.len(), 2); + assert_eq!(packets[1].data_type, 7, "Scarlet Book padding"); + assert!(packets[1].frame_start); + assert_eq!(packets[1].frame_timecode, None); + assert_eq!(packets[1].at + packets[1].len, SECTOR as usize); + } + + #[test] + fn dst_zero_fill_is_alignment_not_audio() { + // Fidelio Disc 2 LSN 969 has this exact header: one 2041-byte audio + // continuation followed by four zero alignment bytes. + let raw = sector(0x21, &[0x17f9]); + let packets = parse_sector(&raw, 969).unwrap(); + assert_eq!(packets.len(), 1); + assert_eq!((packets[0].at, packets[0].len), (3, 2041)); + assert_eq!(packets[0].at + packets[0].len, 2044); + + let mut nonzero_tail = raw; + nonzero_tail[2047] = 1; + assert!(matches!( + parse_sector(&nonzero_tail, 969), + Err(SacdError::BadSector { .. }) + )); + } + + #[test] + fn scarlet_book_text_is_converted_without_replacement_characters() { + assert_eq!( + decode_sacd_text(b"Ouvert\xfcre", 2, "test"), + Some("Ouvertüre".to_string()) + ); + assert_eq!( + decode_sacd_text(b"plain ASCII", 1, "test").as_deref(), + Some("plain ASCII") + ); + assert_eq!(decode_sacd_text(b"bad\xff", 1, "test"), None); + assert_eq!(decode_sacd_text(b"do not guess", 0, "test"), None); + assert_eq!(decode_sacd_text(b"escape\x1bsequence", 7, "test"), None); + } + + #[test] + fn a_changed_image_is_detected_before_more_data_is_trusted() { + use std::io::Write; + + let dir = std::env::temp_dir().join(format!("qbz-sacd-change-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("changed.iso"); + std::fs::write(&path, vec![0u8; SECTOR as usize]).unwrap(); + let image = SectorImage::open(&path).unwrap(); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(&[0u8; 1]) + .unwrap(); + assert!(matches!( + image.verify_unchanged(), + Err(SacdError::ImageChangedDuringRead(_)) + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_partial_sector_has_its_own_error_category() { + let dir = std::env::temp_dir().join(format!("qbz-sacd-length-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("partial.iso"); + std::fs::write(&path, [0u8; 1]).unwrap(); + assert!(matches!( + read_area(&path), + Err(SacdError::InvalidImageLength(1)) + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn dst_track_boundaries_are_taken_from_timecodes_not_shared_sectors() { + use std::io::{Seek, SeekFrom, Write}; + + let dir = std::env::temp_dir().join(format!("qbz-sacd-frame-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("frames.iso"); + let mut file = std::fs::File::create(&path).unwrap(); + for (lsn, timecode) in [(1u64, [0u8, 0, 0]), (2, [0, 0, 1])] { + let mut raw = sector(0x25, &[0x97f9]); + raw[3..6].copy_from_slice(&timecode); + raw[6] = 0x04; + file.seek(SeekFrom::Start(lsn * SECTOR)).unwrap(); + file.write_all(&raw).unwrap(); + } + let track = SacdTrack { + number: 1, + start_lsn: 1, + length_lsn: 2, + start_secs: 0.0, + duration_secs: 1.0 / 75.0, + start_frame: 0, + duration_frames: 1, + encoding: SacdEncoding::Dst, + title: None, + }; + let mut reader = SacdFrameReader::open(&path, &track).unwrap(); + let frame = reader.next_frame().unwrap().unwrap(); + assert_eq!(frame.kind, SacdFrameKind::Dst); + assert_eq!(frame.timecode_frame, 0); + assert_eq!(frame.start_lsn, 1); + assert_eq!(frame.payload.len(), 2041); + assert!(reader.next_frame().unwrap().is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn dst_seek_uses_timecodes_instead_of_assuming_a_constant_compression_ratio() { + use std::io::{Seek, SeekFrom, Write}; + + let dir = std::env::temp_dir().join(format!("qbz-sacd-seek-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("variable-rate.iso"); + let mut file = std::fs::File::create(&path).unwrap(); + let mut lsn = 1u64; + let mut middle_lsn = 0u64; + for frame in 0..100u32 { + if frame == 50 { + middle_lsn = lsn; + } + let sectors = if frame < 50 { 31u8 } else { 1u8 }; + let mut first = sector(0x25, &[0x97f9]); + first[3..6].copy_from_slice(&[0, (frame / 75) as u8, (frame % 75) as u8]); + first[6] = sectors << 2; + file.seek(SeekFrom::Start(lsn * SECTOR)).unwrap(); + file.write_all(&first).unwrap(); + lsn += 1; + for _ in 1..sectors { + let continuation = sector(0x21, &[0x17fd]); + file.seek(SeekFrom::Start(lsn * SECTOR)).unwrap(); + file.write_all(&continuation).unwrap(); + lsn += 1; + } + } + let track = SacdTrack { + number: 1, + start_lsn: 1, + length_lsn: (lsn - 1) as u32, + start_secs: 0.0, + duration_secs: 100.0 / 75.0, + start_frame: 0, + duration_frames: 100, + encoding: SacdEncoding::Dst, + title: None, + }; + let mut reader = SacdFrameReader::open(&path, &track).unwrap(); + reader.seek_to_fraction(50, 100).unwrap(); + let frame = reader.next_frame().unwrap().unwrap(); + assert_eq!(frame.timecode_frame, 50); + assert_eq!(frame.start_lsn, middle_lsn); + + reader.seek_to_fraction(99, 100).unwrap(); + assert_eq!(reader.next_frame().unwrap().unwrap().timecode_frame, 99); + assert!(reader.next_frame().unwrap().is_none()); + + reader.seek_to_fraction(100, 100).unwrap(); + assert!(reader.next_frame().unwrap().is_none()); + + reader.seek_to_fraction(0, 100).unwrap(); + assert_eq!(reader.next_frame().unwrap().unwrap().timecode_frame, 0); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_sector_whose_lengths_do_not_fill_it_is_refused() { // Decoding one wrong is exactly how a fixed-size header skip corrupts @@ -588,13 +1704,16 @@ mod tests { fn an_uncompressed_stereo_frame_is_the_size_that_proves_no_dst() { assert_eq!(DSD64_STEREO_FRAME, 2 * 2_822_400 / 75 / 8); // The measured audio-payload totals divide by it exactly. - assert_eq!(350_495u64 * DSD64_STEREO_FRAME as u64 % DSD64_STEREO_FRAME as u64, 0); + assert_eq!( + 350_495u64 * DSD64_STEREO_FRAME as u64 % DSD64_STEREO_FRAME as u64, + 0 + ); } - /// The scan sniff: PVD + Master TOC signature, nothing else. Built from - /// bare sectors so no real image is needed. + /// The scan sniff: a bounded, internally plausible Master TOC. The ISO + /// compatibility layer is deliberately irrelevant. #[test] - fn sniff_accepts_only_an_iso_with_a_master_toc() { + fn sniff_accepts_raw_and_hybrid_images_with_a_master_toc() { use std::io::{Seek, SeekFrom, Write}; let dir = std::env::temp_dir().join(format!("qbz-sacd-sniff-{}", std::process::id())); @@ -610,19 +1729,46 @@ mod tests { file.write_all(§or).unwrap(); } if let Some(lsn) = toc_lsn { + let mut master = [0u8; 2048]; + master[..8].copy_from_slice(b"SACDMTOC"); + master[8] = 1; + master[9] = 20; + master[0x40..0x44].copy_from_slice(&544u32.to_be_bytes()); + master[0x54..0x56].copy_from_slice(&1u16.to_be_bytes()); file.seek(SeekFrom::Start(lsn * 2048)).unwrap(); - file.write_all(b"SACDMTOC").unwrap(); + file.write_all(&master).unwrap(); } - file.seek(SeekFrom::Start(531 * 2048)).unwrap(); + file.seek(SeekFrom::Start(545 * 2048)).unwrap(); file.write_all(&[0u8; 2048]).unwrap(); path }; - assert!(super::is_sacd_image(&write("sacd.iso", Some(510), true))); - assert!(super::is_sacd_image(&write("backup.iso", Some(530), true))); + for path in [ + write("sacd.iso", Some(510), true), + write("backup.iso", Some(530), true), + write("raw.iso", Some(510), false), + ] { + assert_eq!( + super::sniff_sacd_image(&path).unwrap(), + SacdSniff::Sacd, + "{}", + path.display() + ); + } assert!(!super::is_sacd_image(&write("dvd.iso", None, true))); - assert!(!super::is_sacd_image(&write("nopvd.iso", Some(510), false))); assert!(!super::is_sacd_image(&dir.join("missing.iso"))); + + let truncated = write("truncated.iso", Some(510), false); + std::fs::OpenOptions::new() + .append(true) + .open(&truncated) + .unwrap() + .write_all(&[0]) + .unwrap(); + assert!(matches!( + super::sniff_sacd_image(&truncated), + Err(SacdError::InvalidImageLength(_)) + )); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/qbz-dsd/Cargo.toml b/crates/qbz-dsd/Cargo.toml index 4253d8599..be5ab9cf2 100644 --- a/crates/qbz-dsd/Cargo.toml +++ b/crates/qbz-dsd/Cargo.toml @@ -10,3 +10,4 @@ qbz-disc = { path = "../qbz-disc" } thiserror = { workspace = true } log = { workspace = true } id3 = "1" +dst-decoder = "=0.1.2" diff --git a/crates/qbz-dsd/examples/sacd_verify.rs b/crates/qbz-dsd/examples/sacd_verify.rs new file mode 100644 index 000000000..00b6dd37b --- /dev/null +++ b/crates/qbz-dsd/examples/sacd_verify.rs @@ -0,0 +1,179 @@ +//! Decode complete SACD tracks, check their exact DSD64 byte count and print +//! deterministic per-channel hashes without writing the copyrighted audio. +//! +//! `cargo run --release -p qbz-dsd --example sacd_verify -- [track]` + +use qbz_dsd::{DsdDemuxer, SacdDemuxer}; + +const BYTES_PER_CHANNEL_FRAME: u64 = 4_704; +const BITS_PER_CHANNEL_FRAME: u64 = BYTES_PER_CHANNEL_FRAME * 8; +const READ_FRAMES: usize = 64; +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let image = std::path::PathBuf::from( + args.next() + .ok_or("usage: sacd_verify [track]")?, + ); + let selected = args.next().map(|value| value.parse::()).transpose()?; + if args.next().is_some() { + return Err("usage: sacd_verify [track]".into()); + } + + let area = qbz_disc::sacd::read_area(&image)?; + let tracks = area + .tracks + .iter() + .filter(|track| selected.is_none_or(|number| track.number == number)) + .collect::>(); + if tracks.is_empty() { + return Err(format!("track {} does not exist", selected.unwrap_or_default()).into()); + } + + for track in tracks { + let mut demux = SacdDemuxer::open_default(&image, track)?; + let expected = u64::from(track.duration_frames) * BYTES_PER_CHANNEL_FRAME; + let midpoint_frame = u64::from(track.duration_frames / 2); + let suffix_start = midpoint_frame * BYTES_PER_CHANNEL_FRAME; + let mut total = 0u64; + let mut hashes = [FNV_OFFSET; 2]; + let mut suffix_hashes = [FNV_OFFSET; 2]; + let mut first_frame_hashes = None; + let mut last_frame_hashes = [FNV_OFFSET; 2]; + let mut output = vec![Vec::new(), Vec::new()]; + loop { + output.iter_mut().for_each(Vec::clear); + let read = + demux.read_planar(&mut output, READ_FRAMES * BYTES_PER_CHANNEL_FRAME as usize)?; + if read == 0 { + break; + } + if output.iter().any(|channel| channel.len() != read) { + return Err( + format!("track {} returned unequal channel lengths", track.number).into(), + ); + } + total = total + .checked_add(read as u64) + .ok_or("decoded byte count overflow")?; + let suffix_offset = suffix_start.saturating_sub(total - read as u64) as usize; + for (channel_index, ((hash, suffix_hash), channel)) in hashes + .iter_mut() + .zip(&mut suffix_hashes) + .zip(&output) + .enumerate() + { + *hash = hash_bytes(*hash, channel); + if suffix_offset < channel.len() { + *suffix_hash = hash_bytes(*suffix_hash, &channel[suffix_offset..]); + } + last_frame_hashes[channel_index] = hash_bytes( + FNV_OFFSET, + &channel[channel.len() - BYTES_PER_CHANNEL_FRAME as usize..], + ); + } + if first_frame_hashes.is_none() { + first_frame_hashes = Some([ + hash_bytes(FNV_OFFSET, &output[0][..BYTES_PER_CHANNEL_FRAME as usize]), + hash_bytes(FNV_OFFSET, &output[1][..BYTES_PER_CHANNEL_FRAME as usize]), + ]); + } + } + if total != expected { + return Err(format!( + "track {} decoded {total} bytes/channel, expected {expected}", + track.number + ) + .into()); + } + println!( + "track {:>3}: {total} bytes/channel fnv1a64={:016x}/{:016x}", + track.number, hashes[0], hashes[1] + ); + + // A selected-track run also proves that the seek estimate reacquires + // the exact timecoded frame even when DST compression varies across + // the track. Compare the suffix to a fresh decoder, which exercises + // decoder reset as well as container positioning. + if selected.is_some() { + let mut seeked = SacdDemuxer::open_default(&image, track)?; + seeked.seek_to_bit(midpoint_frame * BITS_PER_CHANNEL_FRAME)?; + let mut seek_total = 0u64; + let mut seek_hashes = [FNV_OFFSET; 2]; + loop { + output.iter_mut().for_each(Vec::clear); + let read = seeked + .read_planar(&mut output, READ_FRAMES * BYTES_PER_CHANNEL_FRAME as usize)?; + if read == 0 { + break; + } + seek_total += read as u64; + for (hash, channel) in seek_hashes.iter_mut().zip(&output) { + *hash = hash_bytes(*hash, channel); + } + } + if seek_total != expected - suffix_start || seek_hashes != suffix_hashes { + return Err(format!( + "track {} midpoint seek disagrees with its linear suffix: bytes={seek_total}/{} hash={:016x}/{:016x} expected={:016x}/{:016x}", + track.number, + expected - suffix_start, + seek_hashes[0], + seek_hashes[1], + suffix_hashes[0], + suffix_hashes[1], + ) + .into()); + } + println!(" midpoint seek verified at frame {midpoint_frame}"); + + let last_frame = u64::from(track.duration_frames) + .checked_sub(1) + .ok_or("zero-duration track")?; + seeked.seek_to_bit(last_frame * BITS_PER_CHANNEL_FRAME)?; + output.iter_mut().for_each(Vec::clear); + let read = seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)?; + let last_seek_hashes = [ + hash_bytes(FNV_OFFSET, &output[0]), + hash_bytes(FNV_OFFSET, &output[1]), + ]; + if read as u64 != BYTES_PER_CHANNEL_FRAME || last_seek_hashes != last_frame_hashes { + return Err(format!("track {} last-frame seek disagrees", track.number).into()); + } + output.iter_mut().for_each(Vec::clear); + if seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)? != 0 { + return Err(format!("track {} emitted data past EOF", track.number).into()); + } + + seeked.seek_to_bit(expected * 8)?; + output.iter_mut().for_each(Vec::clear); + if seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)? != 0 { + return Err(format!("track {} EOF seek emitted data", track.number).into()); + } + + seeked.seek_to_bit(0)?; + output.iter_mut().for_each(Vec::clear); + let read = seeked.read_planar(&mut output, BYTES_PER_CHANNEL_FRAME as usize)?; + let start_seek_hashes = [ + hash_bytes(FNV_OFFSET, &output[0]), + hash_bytes(FNV_OFFSET, &output[1]), + ]; + if read as u64 != BYTES_PER_CHANNEL_FRAME + || Some(start_seek_hashes) != first_frame_hashes + { + return Err(format!("track {} repeated start seek disagrees", track.number).into()); + } + println!(" last-frame, EOF and repeated-start seeks verified"); + } + } + Ok(()) +} diff --git a/crates/qbz-dsd/src/sacd_source.rs b/crates/qbz-dsd/src/sacd_source.rs index ae23f0604..25101cf32 100644 --- a/crates/qbz-dsd/src/sacd_source.rs +++ b/crates/qbz-dsd/src/sacd_source.rs @@ -5,7 +5,8 @@ //! The dependency runs qbz-dsd -> qbz-disc and never the other way: qbz-disc //! owns bytes and geometry, this file owns the audio contract. -use qbz_disc::sacd::{SacdTrack, SacdTrackReader}; +use dst_decoder::decoder::DstDecoder; +use qbz_disc::sacd::{SacdFrameKind, SacdFrameReader, SacdTrack, SacdTrackReader}; use crate::demux::{DsdDemuxer, DsdError, DsdStreamInfo, DsdTags}; @@ -15,10 +16,36 @@ const DSD64: u32 = 2_822_400; const FRAME: usize = 9408; /// Bytes ONE channel contributes to a frame. const FRAME_PER_CH: usize = FRAME / 2; -/// Sectors pulled from the image per refill. 200 sectors is ~0.57 s of audio -/// and keeps the read amortised without holding much. +/// Sectors pulled from the image per legacy diagnostic refill. const REFILL_SECTORS: usize = 200; +fn decode_dst_frame( + decoder: &mut DstDecoder, + scratch: &mut [u8], + payload: &[u8], + pending: &mut Vec, + track: u8, + frame_number: u32, + start_lsn: u64, +) -> Result<(), DsdError> { + scratch.fill(0); + let written = decoder.decode_frame(payload, scratch).map_err(|error| { + DsdError::Corrupt(format!( + "SACD track {track} DST frame {frame_number} at LSN {start_lsn}: {error}" + )) + })?; + if written != FRAME { + return Err(DsdError::Corrupt(format!( + "SACD track {track} DST frame {frame_number} at LSN {start_lsn} decoded {written} bytes, expected {FRAME}" + ))); + } + // Upstream writes 0x55 into `scratch` on Err. Publication is deliberately + // after both Ok and the exact-size check, so synthetic silence cannot + // escape into PCM, DoP or native output. + pending.extend_from_slice(&scratch[..written]); + Ok(()) +} + /// How the two channels sit inside one frame. /// /// Both schemes exist in the wild — DFF interleaves per BYTE, DSF per block — @@ -71,15 +98,26 @@ pub enum ChannelLayout { } pub struct SacdDemuxer { - reader: SacdTrackReader, + reader: SacdReader, info: DsdStreamInfo, layout: ChannelLayout, /// Audio payload read but not yet split into channels. Only whole frames /// are consumed, so a partial frame waits here for the next refill. pending: Vec, + dst_decoder: Option, + decode_scratch: Vec, + track_number: u8, + frame_number: u32, done: bool, } +enum SacdReader { + /// Playback: exact TRL2-bounded frames for both flat DSD and DST. + Framed(SacdFrameReader), + /// Kept only for the `sync=false` diagnostic that reproduces the old bug. + Legacy(SacdTrackReader), +} + impl Default for ChannelLayout { /// The measured layout. A caller that does not care gets the right one. fn default() -> Self { @@ -89,10 +127,7 @@ impl Default for ChannelLayout { impl SacdDemuxer { /// Open with the measured layout. - pub fn open_default( - image: &std::path::Path, - track: &SacdTrack, - ) -> Result { + pub fn open_default(image: &std::path::Path, track: &SacdTrack) -> Result { Self::open(image, track, ChannelLayout::default()) } @@ -113,16 +148,29 @@ impl SacdDemuxer { layout: ChannelLayout, sync: bool, ) -> Result { - let reader = SacdTrackReader::open_with( - image, - track, - if sync { - qbz_disc::sacd::FrameSync::ToFirstFrame - } else { - qbz_disc::sacd::FrameSync::FromPayloadStart - }, - ) - .map_err(|e| DsdError::Corrupt(e.to_string()))?; + let reader = if sync { + SacdReader::Framed( + SacdFrameReader::open(image, track) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + ) + } else { + SacdReader::Legacy( + SacdTrackReader::open_with( + image, + track, + qbz_disc::sacd::FrameSync::FromPayloadStart, + ) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + ) + }; + let dst_decoder = if track.encoding == qbz_disc::sacd::SacdEncoding::Dst { + Some( + DstDecoder::new(2, DSD64 as usize) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + ) + } else { + None + }; Ok(Self { reader, info: DsdStreamInfo { @@ -131,7 +179,7 @@ impl SacdDemuxer { // Bits per channel. The area TOC gives a duration, and DSD64 // is exactly 2 822 400 bits/s, so this is the count rather // than an estimate. - sample_count: (track.duration_secs * DSD64 as f64) as u64, + sample_count: u64::from(track.duration_frames) * u64::from(DSD64 / 75), // SACD is MSB-first, like DFF and unlike DSF's usual LSB-first. // Getting this backwards does not fail — it plays, as noise. lsb_first: false, @@ -143,6 +191,10 @@ impl SacdDemuxer { }, layout, pending: Vec::new(), + dst_decoder, + decode_scratch: vec![0u8; FRAME], + track_number: track.number, + frame_number: 0, done: false, }) } @@ -151,19 +203,54 @@ impl SacdDemuxer { if self.done { return Ok(()); } - let mut chunk = Vec::new(); - match self.reader.next_chunk(&mut chunk, REFILL_SECTORS) { - // ZERO IS NOT THE END. While the reader is still hunting for the - // first frame boundary it drops every audio byte it sees, so an - // empty chunk is an ordinary early read. Only the reader knows - // whether the track is over, and it is asked. - Ok(_) => { + match &mut self.reader { + SacdReader::Legacy(reader) => { + let mut chunk = Vec::new(); + reader + .next_chunk(&mut chunk, REFILL_SECTORS) + .map_err(|error| DsdError::Corrupt(error.to_string()))?; self.pending.extend_from_slice(&chunk); - if self.reader.finished() { - self.done = true; - } + self.done = reader.finished(); } - Err(e) => return Err(DsdError::Corrupt(e.to_string())), + SacdReader::Framed(reader) => match reader + .next_frame() + .map_err(|error| DsdError::Corrupt(error.to_string()))? + { + Some(frame) => { + match frame.kind { + SacdFrameKind::Dsd => { + if frame.payload.len() != FRAME { + return Err(DsdError::Corrupt(format!( + "SACD track {} frame {} at LSN {} produced {} DSD bytes, expected {FRAME}", + self.track_number, + self.frame_number, + frame.start_lsn, + frame.payload.len(), + ))); + } + self.pending.extend_from_slice(&frame.payload); + } + SacdFrameKind::Dst => { + let decoder = self.dst_decoder.as_mut().ok_or_else(|| { + DsdError::Corrupt( + "DST frame reached a stream without a decoder".to_string(), + ) + })?; + decode_dst_frame( + decoder, + &mut self.decode_scratch, + &frame.payload, + &mut self.pending, + self.track_number, + self.frame_number, + frame.start_lsn, + )?; + } + } + self.frame_number = self.frame_number.saturating_add(1); + } + None => self.done = true, + }, } Ok(()) } @@ -176,9 +263,24 @@ impl DsdDemuxer for SacdDemuxer { fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> { let target = bit_per_channel.min(self.info.sample_count); - self.reader.seek_to_fraction(target, self.info.sample_count); + match &mut self.reader { + SacdReader::Framed(reader) => reader + .seek_to_fraction(target, self.info.sample_count) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + SacdReader::Legacy(reader) => reader.seek_to_fraction(target, self.info.sample_count), + } + if self.dst_decoder.is_some() { + self.dst_decoder = Some( + DstDecoder::new(2, DSD64 as usize) + .map_err(|error| DsdError::Corrupt(error.to_string()))?, + ); + } self.pending.clear(); - self.done = self.reader.finished(); + self.frame_number = ((u128::from(target) * 75) / u128::from(DSD64)) as u32; + self.done = match &self.reader { + SacdReader::Framed(reader) => reader.finished(), + SacdReader::Legacy(reader) => reader.finished(), + }; Ok(()) } @@ -281,4 +383,21 @@ mod tests { assert!(r.iter().all(|b| *b == 0x22), "channel 1 is the odd bytes"); assert_eq!(l.len() + r.len(), FRAME, "no byte is dropped or doubled"); } + + #[test] + fn a_corrupt_dst_frame_publishes_none_of_upstreams_synthetic_silence() { + let mut decoder = DstDecoder::new(2, DSD64 as usize).unwrap(); + let mut scratch = vec![0u8; FRAME]; + let mut pending = vec![0x12, 0x34]; + let before = pending.clone(); + + let result = decode_dst_frame(&mut decoder, &mut scratch, &[], &mut pending, 1, 7, 1234); + + assert!(result.is_err()); + assert_eq!(pending, before, "no decoded bytes may be appended on Err"); + assert!( + scratch.iter().all(|byte| *byte == 0x55), + "the test must exercise upstream's error-fill behavior" + ); + } } diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 019e3a380..0725c2e42 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -5430,6 +5430,12 @@ msgstr "Diese Disc ist DST-komprimiert; QBZ kann sie noch nicht abspielen." msgid "That file is not a disc image." msgstr "Diese Datei ist kein Disc-Image." +msgid "That file is not a SACD image." +msgstr "Diese Datei ist kein SACD-Abbild." + +msgid "That SACD image is damaged or unsupported." +msgstr "Dieses SACD-Abbild ist beschädigt oder wird nicht unterstützt." + msgid "SACD" msgstr "SACD" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 5627f83f4..b3f23e1a1 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -960,6 +960,12 @@ msgstr "This disc is DST-compressed, which QBZ cannot play yet." msgid "That file is not a disc image." msgstr "That file is not a disc image." +msgid "That file is not a SACD image." +msgstr "That file is not a SACD image." + +msgid "That SACD image is damaged or unsupported." +msgstr "That SACD image is damaged or unsupported." + msgid "SACD" msgstr "SACD" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 4a475c3c3..1fc6e970d 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -5442,6 +5442,12 @@ msgstr "Este disco está comprimido con DST y QBZ todavía no puede reproducirlo msgid "That file is not a disc image." msgstr "Ese archivo no es una imagen de disco." +msgid "That file is not a SACD image." +msgstr "Ese archivo no es una imagen SACD." + +msgid "That SACD image is damaged or unsupported." +msgstr "Esa imagen SACD está dañada o no es compatible." + msgid "SACD" msgstr "SACD" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 784de45bc..3658216c8 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -5428,6 +5428,12 @@ msgstr "Ce disque est compressé en DST, que QBZ ne peut pas encore lire." msgid "That file is not a disc image." msgstr "Ce fichier n'est pas une image disque." +msgid "That file is not a SACD image." +msgstr "Ce fichier n'est pas une image SACD." + +msgid "That SACD image is damaged or unsupported." +msgstr "Cette image SACD est endommagée ou non prise en charge." + msgid "SACD" msgstr "SACD" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 52143f725..5429c787e 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -5416,6 +5416,12 @@ msgstr "このディスクはDST圧縮のため、QBZではまだ再生できま msgid "That file is not a disc image." msgstr "そのファイルはディスクイメージではありません。" +msgid "That file is not a SACD image." +msgstr "そのファイルはSACDイメージではありません。" + +msgid "That SACD image is damaged or unsupported." +msgstr "そのSACDイメージは破損しているか、サポートされていません。" + msgid "SACD" msgstr "SACD" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index a88dcd575..13d114ddd 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -5418,6 +5418,12 @@ msgstr "Deze schijf is DST-gecomprimeerd; QBZ kan die nog niet afspelen." msgid "That file is not a disc image." msgstr "Dat bestand is geen schijfimage." +msgid "That file is not a SACD image." +msgstr "Dat bestand is geen SACD-image." + +msgid "That SACD image is damaged or unsupported." +msgstr "Die SACD-image is beschadigd of wordt niet ondersteund." + msgid "SACD" msgstr "SACD" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 0c2f1604f..473318fbb 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -5428,6 +5428,12 @@ msgstr "Este disco está comprimido em DST, que o QBZ ainda não reproduz." msgid "That file is not a disc image." msgstr "Esse arquivo não é uma imagem de disco." +msgid "That file is not a SACD image." +msgstr "Esse arquivo não é uma imagem SACD." + +msgid "That SACD image is damaged or unsupported." +msgstr "Essa imagem SACD está danificada ou não é compatível." + msgid "SACD" msgstr "SACD" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 1a4668b0a..d0ffd53f0 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -5447,6 +5447,12 @@ msgstr "Диск сжат в DST — QBZ пока не может его вос msgid "That file is not a disc image." msgstr "Это не образ диска." +msgid "That file is not a SACD image." +msgstr "Это не образ SACD." + +msgid "That SACD image is damaged or unsupported." +msgstr "Этот образ SACD повреждён или не поддерживается." + msgid "SACD" msgstr "SACD" diff --git a/crates/qbz-library/src/database.rs b/crates/qbz-library/src/database.rs index d132f2272..346e8d641 100644 --- a/crates/qbz-library/src/database.rs +++ b/crates/qbz-library/src/database.rs @@ -1,7 +1,9 @@ //! SQLite database layer for library persistence use rusqlite::{params, Connection, OptionalExtension}; -use std::path::Path; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; use std::time::Duration; use crate::{ @@ -37,6 +39,19 @@ pub struct LibraryDatabase { conn: Connection, } +/// Qt creates several library-backed singletons and starts the folder scanner +/// during the same startup turn. Each owns a separate SQLite connection, but +/// schema discovery plus `ALTER TABLE` is a check-then-act sequence and must +/// not run concurrently inside this process. +static SCHEMA_INIT_LOCK: Mutex<()> = Mutex::new(()); + +/// Scope reconciliation walks every indexed SACD against every registered +/// root. One pass per database and process is enough: runtime folder changes +/// update ownership directly, while the next process start repairs legacy or +/// externally modified state again. +static SACD_SCOPE_RECONCILED: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + /// A SQL predicate restricting the shared remote mirror to the sources the user /// has enabled. /// @@ -68,6 +83,10 @@ impl LibraryDatabase { conn.busy_timeout(Duration::from_millis(2_500)) .map_err(|e| LibraryError::Database(format!("Failed to set busy timeout: {}", e)))?; + let schema_guard = SCHEMA_INIT_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // Enable WAL mode for better concurrent access conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") .map_err(|e| LibraryError::Database(format!("Failed to set WAL mode: {}", e)))?; @@ -75,6 +94,18 @@ impl LibraryDatabase { let db = Self { conn }; db.init_schema()?; db.run_migrations()?; + let needs_sacd_scope_reconcile = SACD_SCOPE_RECONCILED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(db_path) + .is_none(); + if needs_sacd_scope_reconcile { + db.reconcile_sacd_catalog_scope()?; + SACD_SCOPE_RECONCILED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(db_path.to_path_buf()); + } // First-class LOCAL playlists (offline-mode D7) — separate module, // same database file. Idempotent CREATE IF NOT EXISTS. crate::local_playlists::init_schema(&db.conn) @@ -84,6 +115,7 @@ impl LibraryDatabase { crate::qobuz_playlist_snapshot::init_schema(&db.conn).map_err(|e| { LibraryError::Database(format!("qobuz_playlist_snapshot schema: {}", e)) })?; + drop(schema_guard); Ok(db) } @@ -147,7 +179,8 @@ impl LibraryDatabase { image_path TEXT NOT NULL UNIQUE, image_size_bytes INTEGER NOT NULL, image_modified_ns INTEGER NOT NULL, - observed_at INTEGER NOT NULL + observed_at INTEGER NOT NULL, + parser_revision INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS local_sacd_tracks ( @@ -160,6 +193,19 @@ impl LibraryDatabase { CREATE INDEX IF NOT EXISTS idx_local_sacd_tracks_local_id ON local_sacd_tracks(local_track_id); + -- A registered Local Library root owns an indexed SACD. Keeping + -- this separate from the image allows overlapping roots without + -- making a manually opened image persistent. + CREATE TABLE IF NOT EXISTS local_sacd_roots ( + root_id INTEGER NOT NULL, + fingerprint TEXT NOT NULL, + observed_generation INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(root_id, fingerprint) + ); + + CREATE INDEX IF NOT EXISTS idx_local_sacd_roots_fingerprint + ON local_sacd_roots(fingerprint); + -- Incremental scanner state. `local_tracks` remains authoritative; -- these tables only remember what each root observed and whether -- a completed generation is allowed to prune stale rows. @@ -411,6 +457,42 @@ impl LibraryDatabase { /// Run schema migrations for existing databases fn run_migrations(&self) -> Result<(), LibraryError> { + let has_sacd_parser_revision: bool = self + .conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_sacd_images') WHERE name = 'parser_revision'", + [], + |row| row.get::<_, i32>(0), + ) + .map(|count| count > 0) + .unwrap_or(false); + if !has_sacd_parser_revision { + log::info!("Running migration: adding parser_revision to local_sacd_images"); + if let Err(error) = self.conn.execute_batch( + "ALTER TABLE local_sacd_images ADD COLUMN parser_revision INTEGER NOT NULL DEFAULT 0;", + ) { + // The in-process lock handles QBZ's startup fan-out. This + // second check also covers two QBZ processes migrating the + // same profile: accept the losing ALTER only if the intended + // schema is now observable, never by matching error text. + let installed_by_peer = self + .conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_sacd_images') WHERE name = 'parser_revision'", + [], + |row| row.get::<_, i32>(0), + ) + .map(|count| count > 0) + .unwrap_or(false); + if !installed_by_peer { + return Err(LibraryError::Database(format!( + "SACD parser revision migration failed: {error}" + ))); + } + log::debug!("SACD parser revision migration completed by another process"); + } + } + // Migration: Add qobuz download tracking fields let has_source: bool = self .conn @@ -1057,6 +1139,11 @@ impl LibraryDatabase { .optional() .map_err(|e| LibraryError::Database(e.to_string()))?; if let Some(root_id) = root_id { + // A newly registered overlapping root may not have scanned yet. + // Materialize every path-based owner before dropping this one so + // removing the outer folder cannot orphan a still-owned image. + self.reconcile_sacd_catalog_scope()?; + self.remove_sacd_root(root_id)?; self.conn .execute( "DELETE FROM local_scan_files WHERE root_id = ?", @@ -1264,6 +1351,7 @@ impl LibraryDatabase { params![id], ) .map_err(|e| LibraryError::Database(e.to_string()))?; + self.reconcile_sacd_catalog_scope()?; Ok(()) } @@ -1613,6 +1701,7 @@ impl LibraryDatabase { "BEGIN IMMEDIATE; DELETE FROM local_tracks WHERE source IS NULL OR source != 'qobuz_download'; DELETE FROM local_sacd_tracks; + DELETE FROM local_sacd_roots; DELETE FROM local_sacd_images; DELETE FROM local_scan_files; DELETE FROM local_scan_cue_refs; @@ -9021,3 +9110,55 @@ mod purchase_registry_reachability_tests { crate::reachability::reset_cooldowns(); } } + +#[cfg(test)] +mod schema_init_concurrency_tests { + use super::*; + use std::sync::{Arc, Barrier}; + + #[test] + fn concurrent_opens_serialize_a_legacy_schema_migration() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("library.db"); + let legacy = Connection::open(&path).unwrap(); + legacy + .execute_batch( + "CREATE TABLE local_sacd_images ( + fingerprint TEXT PRIMARY KEY, + image_path TEXT NOT NULL UNIQUE, + image_size_bytes INTEGER NOT NULL, + image_modified_ns INTEGER NOT NULL, + observed_at INTEGER NOT NULL + );", + ) + .unwrap(); + drop(legacy); + + const THREADS: usize = 8; + let barrier = Arc::new(Barrier::new(THREADS)); + let handles = (0..THREADS) + .map(|_| { + let path = path.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + LibraryDatabase::open(&path).map(drop) + }) + }) + .collect::>(); + for handle in handles { + handle.join().unwrap().unwrap(); + } + + let db = Connection::open(&path).unwrap(); + let columns: i64 = db + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('local_sacd_images') + WHERE name='parser_revision'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(columns, 1); + } +} diff --git a/crates/qbz-library/src/lib.rs b/crates/qbz-library/src/lib.rs index efd19c3f1..3351f1a29 100644 --- a/crates/qbz-library/src/lib.rs +++ b/crates/qbz-library/src/lib.rs @@ -71,7 +71,9 @@ pub use reachability::{ probe, probe_default, probe_file, probe_file_default, FileProbe, Reach, DEFAULT_PROBE, }; pub use sacd::{SacdImageImport, SacdImportResult}; -pub use sacd_scan::{build_image_rows, SacdImageRows, SacdLabels, SacdScanSummary}; +pub use sacd_scan::{ + build_image_rows, SacdImageRows, SacdLabels, SacdScanSummary, SACD_PARSER_REVISION, +}; pub use scan::{scan_with_progress, ScanEvent}; pub use scanner::{ LibraryScanner, ScanEntry, ScanFileKind, ScanResult, ScanStream, ScanWalkError, SymlinkPolicy, diff --git a/crates/qbz-library/src/sacd.rs b/crates/qbz-library/src/sacd.rs index e12041805..d1db6d426 100644 --- a/crates/qbz-library/src/sacd.rs +++ b/crates/qbz-library/src/sacd.rs @@ -7,6 +7,7 @@ //! prune the last known-good rows. use std::collections::{HashMap, HashSet}; +use std::path::Path; use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; @@ -22,6 +23,8 @@ pub struct SacdImageImport { pub image_size_bytes: u64, pub image_modified_ns: i64, pub observed_at: i64, + /// Scan-cache schema/parser generation. Not part of the fingerprint. + pub parser_revision: i64, /// Complete stereo-area track set. Each path must be /// `sacd:#`. pub tracks: Vec, @@ -39,36 +42,65 @@ pub struct SacdImportResult { } impl LibraryDatabase { - /// Atomically adopt a fully parsed SACD image into the local library. - /// - /// Whether an image at this path was already imported with exactly this - /// size and mtime — the folder scan's zero-I/O skip for a known disc. - pub fn sacd_image_unchanged( + /// Mark a known image as seen by one registered library root, and report + /// whether its file facts and parser revision still match. Unknown paths + /// remain unowned until a complete parse is imported. + pub fn observe_sacd_image( &self, + root_id: i64, + observed_generation: i64, image_path: &str, size_bytes: u64, modified_ns: i64, + parser_revision: i64, ) -> Result { self.with_connection(|connection| { - connection + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + ensure_registered_root_owns_path(&transaction, root_id, image_path)?; + let known = transaction .query_row( - "SELECT 1 FROM local_sacd_images - WHERE image_path=?1 AND image_size_bytes=?2 AND image_modified_ns=?3", - params![image_path, to_i64(size_bytes), modified_ns], - |_| Ok(()), + "SELECT fingerprint,image_size_bytes,image_modified_ns,parser_revision + FROM local_sacd_images WHERE image_path=?1", + params![image_path], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }, ) .optional() - .map(|row| row.is_some()) - .map_err(database_error) + .map_err(database_error)?; + let unchanged = + if let Some((fingerprint, known_size, known_mtime, known_revision)) = known { + observe_root(&transaction, root_id, &fingerprint, observed_generation)?; + known_size == to_i64(size_bytes) + && known_mtime == modified_ns + && known_revision == parser_revision + } else { + false + }; + transaction.commit().map_err(database_error)?; + Ok(unchanged) }) } + /// Atomically adopt a fully parsed SACD image found by a registered Local + /// Library root. Requiring the root id and generation keeps this API out of + /// the manual/ephemeral open path by construction. + /// /// The geometry fingerprint, not the file path, owns the row ids. Moving /// an image and opening it again therefore preserves playlists/history /// references. A successful shorter generation prunes obsolete virtual /// tracks; validation or SQL failure rolls the whole operation back. pub fn import_sacd_image( &self, + root_id: i64, + observed_generation: i64, import: &SacdImageImport, ) -> Result { let ordered = validate_import(import)?; @@ -79,6 +111,7 @@ impl LibraryDatabase { let transaction = Transaction::new_unchecked(connection, TransactionBehavior::Immediate) .map_err(database_error)?; + ensure_registered_root_owns_path(&transaction, root_id, &import.image_path)?; let latest_observation = transaction .query_row( "SELECT MAX(observed_at) FROM local_sacd_images @@ -101,7 +134,25 @@ impl LibraryDatabase { rows.collect::, _>>() .map_err(database_error)? }; - transaction.rollback().map_err(database_error)?; + let current_fingerprint = transaction + .query_row( + "SELECT fingerprint FROM local_sacd_images WHERE image_path=?1", + params![import.image_path], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)?; + if current_fingerprint.as_deref() == Some(import.fingerprint.as_str()) { + observe_root( + &transaction, + root_id, + &import.fingerprint, + observed_generation, + )?; + transaction.commit().map_err(database_error)?; + } else { + transaction.rollback().map_err(database_error)?; + } return Ok(SacdImportResult { track_ids, inserted: 0, @@ -234,27 +285,319 @@ impl LibraryDatabase { transaction .execute( "INSERT INTO local_sacd_images - (fingerprint,image_path,image_size_bytes,image_modified_ns,observed_at) - VALUES (?1,?2,?3,?4,?5) + (fingerprint,image_path,image_size_bytes,image_modified_ns,observed_at,parser_revision) + VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(fingerprint) DO UPDATE SET image_path=excluded.image_path, image_size_bytes=excluded.image_size_bytes, image_modified_ns=excluded.image_modified_ns, - observed_at=excluded.observed_at", + observed_at=excluded.observed_at, + parser_revision=excluded.parser_revision", params![ import.fingerprint, import.image_path, to_i64(import.image_size_bytes), import.image_modified_ns, import.observed_at, + import.parser_revision, ], ) .map_err(database_error)?; + observe_root( + &transaction, + root_id, + &import.fingerprint, + observed_generation, + )?; + transaction.commit().map_err(database_error)?; Ok(result) }) } + + /// Backfill root ownership for pre-fix indexed rows and remove generations + /// that are outside every configured Local Library folder. This is + /// intentionally idempotent: `LibraryDatabase::open` may run many times in + /// one Qt startup. + pub(crate) fn reconcile_sacd_catalog_scope(&self) -> Result<(), LibraryError> { + let (linked, unlinked, removed_images, removed_tracks) = self.with_connection( + |connection| -> Result<(usize, usize, usize, usize), LibraryError> { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + let roots: Vec<(i64, String)> = { + let mut statement = transaction + .prepare("SELECT id,path FROM library_folders") + .map_err(database_error)?; + let rows = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + rows + }; + let images: Vec<(String, String)> = { + let mut statement = transaction + .prepare("SELECT fingerprint,image_path FROM local_sacd_images") + .map_err(database_error)?; + let rows = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + rows + }; + + let mut linked = 0usize; + for (fingerprint, image_path) in &images { + for (root_id, root_path) in &roots { + if path_is_within(image_path, root_path) { + linked += transaction + .execute( + "INSERT OR IGNORE INTO local_sacd_roots + (root_id,fingerprint,observed_generation) + VALUES (?1,?2,0)", + params![root_id, fingerprint], + ) + .map_err(database_error)?; + } + } + } + + let mappings: Vec<(i64, String, Option, Option)> = { + let mut statement = transaction + .prepare( + "SELECT owned.root_id,owned.fingerprint,folders.path,images.image_path + FROM local_sacd_roots owned + LEFT JOIN library_folders folders ON folders.id=owned.root_id + LEFT JOIN local_sacd_images images + ON images.fingerprint=owned.fingerprint", + ) + .map_err(database_error)?; + let rows = statement + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + rows + }; + let mut unlinked = 0usize; + for (root_id, fingerprint, root_path, image_path) in mappings { + let valid = root_path + .as_deref() + .zip(image_path.as_deref()) + .is_some_and(|(root, image)| path_is_within(image, root)); + if !valid { + unlinked += transaction + .execute( + "DELETE FROM local_sacd_roots + WHERE root_id=?1 AND fingerprint=?2", + params![root_id, fingerprint], + ) + .map_err(database_error)?; + } + } + + let orphaned: Vec = { + let mut statement = transaction + .prepare( + "SELECT images.fingerprint FROM local_sacd_images images + WHERE NOT EXISTS ( + SELECT 1 FROM local_sacd_roots owned + WHERE owned.fingerprint=images.fingerprint + )", + ) + .map_err(database_error)?; + let rows = statement + .query_map([], |row| row.get(0)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + rows + }; + let mut removed_tracks = 0usize; + for fingerprint in &orphaned { + removed_tracks += remove_generation(&transaction, fingerprint)?; + } + let removed_images = orphaned.len(); + transaction.commit().map_err(database_error)?; + Ok((linked, unlinked, removed_images, removed_tracks)) + }, + )?; + if linked > 0 || unlinked > 0 || removed_images > 0 { + log::info!( + "[sacd] catalogue scope reconciled: linked={linked} unlinked={unlinked} removed_images={removed_images} removed_tracks={removed_tracks}" + ); + } + Ok(()) + } + + /// Drop one root's ownership and remove only generations that no other + /// registered root owns. + pub(crate) fn remove_sacd_root(&self, root_id: i64) -> Result { + self.with_connection(|connection| { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + let removed = remove_root_ownership(&transaction, root_id, None)?; + transaction.commit().map_err(database_error)?; + Ok(removed) + }) + } + + /// Complete one successful SACD walk: stale observations under this root + /// disappear, while overlapping roots retain their shared generations. + pub(crate) fn finish_sacd_root_scan( + &self, + root_id: i64, + observed_generation: i64, + ) -> Result { + self.with_connection(|connection| { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + let removed = remove_root_ownership(&transaction, root_id, Some(observed_generation))?; + transaction.commit().map_err(database_error)?; + Ok(removed) + }) + } + + /// A known SACD path that now has a valid non-SACD signature was replaced; + /// remove its complete generation and every ownership edge. + pub(crate) fn remove_sacd_image_at_path( + &self, + image_path: &str, + ) -> Result { + self.with_connection(|connection| { + let transaction = + Transaction::new_unchecked(connection, TransactionBehavior::Immediate) + .map_err(database_error)?; + let fingerprint = transaction + .query_row( + "SELECT fingerprint FROM local_sacd_images WHERE image_path=?1", + params![image_path], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)?; + let removed = match fingerprint { + Some(fingerprint) => remove_generation(&transaction, &fingerprint)?, + None => 0, + }; + transaction.commit().map_err(database_error)?; + Ok(removed) + }) + } +} + +fn ensure_registered_root_owns_path( + transaction: &Transaction<'_>, + root_id: i64, + image_path: &str, +) -> Result<(), LibraryError> { + let root_path = transaction + .query_row( + "SELECT path FROM library_folders WHERE id=?1", + params![root_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)? + .ok_or_else(|| LibraryError::InvalidPath("SACD scan root is not registered".to_string()))?; + if !path_is_within(image_path, &root_path) { + return Err(LibraryError::InvalidPath(format!( + "SACD image is outside its registered scan root: {image_path}" + ))); + } + Ok(()) +} + +fn observe_root( + transaction: &Transaction<'_>, + root_id: i64, + fingerprint: &str, + observed_generation: i64, +) -> Result<(), LibraryError> { + transaction + .execute( + "INSERT INTO local_sacd_roots(root_id,fingerprint,observed_generation) + VALUES (?1,?2,?3) + ON CONFLICT(root_id,fingerprint) DO UPDATE SET + observed_generation=excluded.observed_generation", + params![root_id, fingerprint, observed_generation], + ) + .map_err(database_error)?; + Ok(()) +} + +fn remove_root_ownership( + transaction: &Transaction<'_>, + root_id: i64, + keep_generation: Option, +) -> Result { + let fingerprints: Vec = if let Some(generation) = keep_generation { + let mut statement = transaction + .prepare( + "SELECT fingerprint FROM local_sacd_roots + WHERE root_id=?1 AND observed_generation<>?2", + ) + .map_err(database_error)?; + let rows = statement + .query_map(params![root_id, generation], |row| row.get(0)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + rows + } else { + let mut statement = transaction + .prepare("SELECT fingerprint FROM local_sacd_roots WHERE root_id=?1") + .map_err(database_error)?; + let rows = statement + .query_map(params![root_id], |row| row.get(0)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + rows + }; + match keep_generation { + Some(generation) => transaction + .execute( + "DELETE FROM local_sacd_roots + WHERE root_id=?1 AND observed_generation<>?2", + params![root_id, generation], + ) + .map_err(database_error)?, + None => transaction + .execute( + "DELETE FROM local_sacd_roots WHERE root_id=?1", + params![root_id], + ) + .map_err(database_error)?, + }; + + let mut removed = 0usize; + for fingerprint in fingerprints { + let still_owned = transaction + .query_row( + "SELECT 1 FROM local_sacd_roots WHERE fingerprint=?1 LIMIT 1", + params![fingerprint], + |_| Ok(()), + ) + .optional() + .map_err(database_error)? + .is_some(); + if !still_owned { + removed += remove_generation(transaction, &fingerprint)?; + } + } + Ok(removed) +} + +fn path_is_within(path: &str, root: &str) -> bool { + !root.is_empty() && Path::new(path).starts_with(Path::new(root)) } fn validate_import(import: &SacdImageImport) -> Result, LibraryError> { @@ -327,6 +670,12 @@ fn remove_generation( params![fingerprint], ) .map_err(database_error)?; + transaction + .execute( + "DELETE FROM local_sacd_roots WHERE fingerprint=?1", + params![fingerprint], + ) + .map_err(database_error)?; transaction .execute( "DELETE FROM local_sacd_images WHERE fingerprint=?1", @@ -450,10 +799,13 @@ mod tests { use super::*; use tempfile::TempDir; - fn fresh_db() -> (TempDir, LibraryDatabase) { + fn fresh_db() -> (TempDir, LibraryDatabase, i64) { let temp = TempDir::new().unwrap(); let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); - (temp, db) + let root_id = db + .add_folder_with_network_info("/music", false, None) + .unwrap(); + (temp, db, root_id) } fn image(fingerprint: &str, path: &str, count: u32) -> SacdImageImport { @@ -463,6 +815,7 @@ mod tests { image_size_bytes: 4_700_000_000, image_modified_ns: 123, observed_at: 456, + parser_revision: 2, tracks: (1..=count) .map(|number| LocalTrack { file_path: format!("sacd:{path}#{number}"), @@ -486,11 +839,130 @@ mod tests { } } + fn seed_unowned_generation(db: &LibraryDatabase, import: &SacdImageImport) -> Vec { + let ids = import + .tracks + .iter() + .map(|track| db.insert_track(track).unwrap()) + .collect::>(); + db.with_connection(|connection| { + connection + .execute( + "INSERT INTO local_sacd_images + (fingerprint,image_path,image_size_bytes,image_modified_ns, + observed_at,parser_revision) + VALUES (?1,?2,?3,?4,?5,?6)", + params![ + import.fingerprint, + import.image_path, + to_i64(import.image_size_bytes), + import.image_modified_ns, + import.observed_at, + import.parser_revision, + ], + ) + .unwrap(); + for (track, id) in import.tracks.iter().zip(&ids) { + connection + .execute( + "INSERT INTO local_sacd_tracks + (fingerprint,track_number,local_track_id) + VALUES (?1,?2,?3)", + params![import.fingerprint, track.track_number, id], + ) + .unwrap(); + } + }); + ids + } + + #[test] + fn catalogue_import_requires_a_registered_owning_root() { + let (_temp, db, root_id) = fresh_db(); + assert!(db + .import_sacd_image(9999, 1, &image("unregistered", "/music/disc.iso", 1)) + .is_err()); + assert!(db + .import_sacd_image(root_id, 1, &image("sibling", "/music-2/disc.iso", 1)) + .is_err()); + let rows: i64 = db + .with_connection(|connection| { + connection.query_row("SELECT COUNT(*) FROM local_sacd_images", [], |row| { + row.get(0) + }) + }) + .unwrap(); + assert_eq!(rows, 0); + } + + #[test] + fn scope_reconciliation_adopts_scanned_legacy_rows_and_purges_manual_ones() { + let (_temp, db, root_id) = fresh_db(); + let inside = image("inside", "/music/album/disc.iso", 2); + let outside = image("outside", "/downloads/opened.iso", 3); + let inside_ids = seed_unowned_generation(&db, &inside); + let outside_ids = seed_unowned_generation(&db, &outside); + + db.reconcile_sacd_catalog_scope().unwrap(); + + assert!(inside_ids + .iter() + .all(|id| db.get_track(*id).unwrap().is_some())); + assert!(outside_ids + .iter() + .all(|id| db.get_track(*id).unwrap().is_none())); + let state: (i64, i64, i64) = db + .with_connection(|connection| { + connection.query_row( + "SELECT + (SELECT COUNT(*) FROM local_sacd_images), + (SELECT COUNT(*) FROM local_sacd_roots WHERE root_id=?1), + (SELECT COUNT(*) FROM local_sacd_tracks)", + params![root_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + }) + .unwrap(); + assert_eq!(state, (1, 1, 2)); + } + + #[test] + fn removing_roots_respects_overlap_and_last_owner_removes_the_disc() { + let (_temp, db, outer_root) = fresh_db(); + let inner_root = db + .add_folder_with_network_info("/music/album", false, None) + .unwrap(); + let import = image("shared", "/music/album/disc.iso", 2); + let ids = db + .import_sacd_image(outer_root, 1, &import) + .unwrap() + .track_ids; + + db.remove_folder("/music").unwrap(); + assert!(ids.iter().all(|id| db.get_track(*id).unwrap().is_some())); + + db.remove_folder("/music/album").unwrap(); + assert!(ids.iter().all(|id| db.get_track(*id).unwrap().is_none())); + assert_ne!(inner_root, outer_root); + } + + #[test] + fn a_complete_root_generation_prunes_a_disappeared_image() { + let (_temp, db, root_id) = fresh_db(); + let ids = db + .import_sacd_image(root_id, 1, &image("gone", "/music/gone.iso", 2)) + .unwrap() + .track_ids; + + assert_eq!(db.finish_sacd_root_scan(root_id, 2).unwrap(), 2); + assert!(ids.iter().all(|id| db.get_track(*id).unwrap().is_none())); + } + #[test] fn import_is_persistent_and_reimporting_a_moved_image_keeps_row_ids() { - let (_temp, db) = fresh_db(); + let (_temp, db, root_id) = fresh_db(); let first = db - .import_sacd_image(&image("sacd-a", "/music/old.iso", 3)) + .import_sacd_image(root_id, 1, &image("sacd-a", "/music/old.iso", 3)) .unwrap(); assert_eq!((first.inserted, first.updated, first.removed), (3, 0, 0)); db.add_local_track_to_playlist(7, first.track_ids[1], 0) @@ -498,7 +970,10 @@ mod tests { let mut moved = image("sacd-a", "/nas/new.iso", 3); moved.tracks[1].title = "Corrected".to_string(); - let second = db.import_sacd_image(&moved).unwrap(); + let moved_root = db + .add_folder_with_network_info("/nas", true, Some("nfs")) + .unwrap(); + let second = db.import_sacd_image(moved_root, 2, &moved).unwrap(); assert_eq!((second.inserted, second.updated, second.removed), (0, 3, 0)); assert_eq!(second.track_ids, first.track_ids); assert_eq!( @@ -533,20 +1008,58 @@ mod tests { assert_eq!(source, "user", "the existing local router must own the row"); } + #[test] + fn a_parser_revision_invalidates_only_the_scan_cache() { + let (_temp, db, root_id) = fresh_db(); + let import = image("sacd-a", "/music/disc.iso", 2); + let ids = db.import_sacd_image(root_id, 1, &import).unwrap().track_ids; + + assert!(db + .observe_sacd_image( + root_id, + 2, + &import.image_path, + import.image_size_bytes, + import.image_modified_ns, + import.parser_revision, + ) + .unwrap()); + assert!(!db + .observe_sacd_image( + root_id, + 3, + &import.image_path, + import.image_size_bytes, + import.image_modified_ns, + import.parser_revision + 1, + ) + .unwrap()); + + let mut reparsed = import; + reparsed.parser_revision += 1; + reparsed.observed_at += 1; + assert_eq!( + db.import_sacd_image(root_id, 4, &reparsed) + .unwrap() + .track_ids, + ids + ); + } + #[test] fn only_a_complete_successful_generation_prunes_obsolete_tracks() { - let (_temp, db) = fresh_db(); + let (_temp, db, root_id) = fresh_db(); let first = db - .import_sacd_image(&image("sacd-a", "/music/disc.iso", 4)) + .import_sacd_image(root_id, 1, &image("sacd-a", "/music/disc.iso", 4)) .unwrap(); let mut invalid = image("sacd-a", "/music/disc.iso", 2); invalid.tracks[1].file_path = "sacd:/different.iso#2".to_string(); - assert!(db.import_sacd_image(&invalid).is_err()); + assert!(db.import_sacd_image(root_id, 2, &invalid).is_err()); assert!(db.get_track(first.track_ids[3]).unwrap().is_some()); let shorter = db - .import_sacd_image(&image("sacd-a", "/music/disc.iso", 2)) + .import_sacd_image(root_id, 3, &image("sacd-a", "/music/disc.iso", 2)) .unwrap(); assert_eq!(shorter.removed, 2); assert_eq!(shorter.track_ids, first.track_ids[..2]); @@ -556,16 +1069,16 @@ mod tests { #[test] fn an_older_completed_snapshot_cannot_overwrite_a_newer_correction() { - let (_temp, db) = fresh_db(); + let (_temp, db, root_id) = fresh_db(); let mut newer = image("sacd-a", "/music/disc.iso", 2); newer.observed_at = 200; newer.tracks[0].title = "New naming".to_string(); - db.import_sacd_image(&newer).unwrap(); + db.import_sacd_image(root_id, 2, &newer).unwrap(); let mut older = image("sacd-a", "/music/disc.iso", 2); older.observed_at = 100; older.tracks[0].title = "Late old naming".to_string(); - let outcome = db.import_sacd_image(&older).unwrap(); + let outcome = db.import_sacd_image(root_id, 1, &older).unwrap(); assert!(outcome.stale); assert_eq!( @@ -580,12 +1093,12 @@ mod tests { #[test] fn a_successfully_parsed_replacement_at_the_same_path_replaces_the_old_disc() { - let (_temp, db) = fresh_db(); + let (_temp, db, root_id) = fresh_db(); let old = db - .import_sacd_image(&image("sacd-old", "/music/disc.iso", 2)) + .import_sacd_image(root_id, 1, &image("sacd-old", "/music/disc.iso", 2)) .unwrap(); let new = db - .import_sacd_image(&image("sacd-new", "/music/disc.iso", 3)) + .import_sacd_image(root_id, 2, &image("sacd-new", "/music/disc.iso", 3)) .unwrap(); assert_eq!(new.removed, 2); @@ -610,32 +1123,33 @@ mod tests { #[test] fn clear_library_removes_tracks_and_sacd_identity_together() { - let (_temp, db) = fresh_db(); - db.import_sacd_image(&image("sacd-a", "/music/disc.iso", 2)) + let (_temp, db, root_id) = fresh_db(); + db.import_sacd_image(root_id, 1, &image("sacd-a", "/music/disc.iso", 2)) .unwrap(); db.clear_all_tracks().unwrap(); - let counts: (i64, i64, i64) = db + let counts: (i64, i64, i64, i64) = db .with_connection(|connection| { connection.query_row( "SELECT (SELECT COUNT(*) FROM local_tracks), (SELECT COUNT(*) FROM local_sacd_images), - (SELECT COUNT(*) FROM local_sacd_tracks)", + (SELECT COUNT(*) FROM local_sacd_tracks), + (SELECT COUNT(*) FROM local_sacd_roots)", [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) }) .unwrap(); - assert_eq!(counts, (0, 0, 0)); + assert_eq!(counts, (0, 0, 0, 0)); } #[test] fn the_full_sacd_track_number_domain_imports_without_a_hidden_cap() { - let (_temp, db) = fresh_db(); + let (_temp, db, root_id) = fresh_db(); let started = std::time::Instant::now(); let result = db - .import_sacd_image(&image("sacd-max", "/music/max.iso", 255)) + .import_sacd_image(root_id, 1, &image("sacd-max", "/music/max.iso", 255)) .unwrap(); let sqlite_bytes: i64 = db .with_connection(|connection| { diff --git a/crates/qbz-library/src/sacd_scan.rs b/crates/qbz-library/src/sacd_scan.rs index 9369c4437..1868e5a24 100644 --- a/crates/qbz-library/src/sacd_scan.rs +++ b/crates/qbz-library/src/sacd_scan.rs @@ -18,10 +18,14 @@ use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; -use qbz_disc::sacd::{read_area, SacdError}; +use qbz_disc::sacd::{read_area, SacdError, SacdSniff}; use crate::{AudioFormat, LibraryDatabase, LocalTrack, MetadataExtractor, SacdImageImport}; +/// Bump when a successful TOC parse can expose a different complete snapshot. +/// This invalidates only the scan cache; disc identity remains geometry-only. +pub const SACD_PARSER_REVISION: i64 = 2; + /// Fallback naming for an image whose Master TOC carries no text. #[derive(Debug, Clone)] pub struct SacdLabels { @@ -235,6 +239,7 @@ pub fn build_image_rows(path: &Path, labels: &SacdLabels) -> Result, } @@ -269,16 +278,19 @@ fn is_iso(path: &Path) -> bool { } /// Find and import the SACD images under one root. Runs after the root's -/// audio/cue passes; nothing here touches `local_scan_files` or the audio -/// prune, and a disc that disappears keeps its rows (the same rule the manual -/// import follows for a NAS that is down). +/// audio/cue passes; nothing here touches `local_scan_files`. A complete walk +/// prunes images no longer present under this root, while cancellation or a +/// traversal error preserves the last known-good rows. pub fn scan_root_for_sacd( db: &LibraryDatabase, + root_id: i64, + observed_generation: i64, root: &Path, labels: &SacdLabels, cancel: &AtomicBool, ) -> SacdScanSummary { let mut summary = SacdScanSummary::default(); + let mut traversal_complete = true; let walk = walkdir::WalkDir::new(root) .follow_links(true) .sort_by_file_name() @@ -291,6 +303,7 @@ pub fn scan_root_for_sacd( Ok(entry) => entry, Err(error) => { log::debug!("[sacd] scan walk error: {error}"); + traversal_complete = false; continue; } }; @@ -303,26 +316,53 @@ pub fn scan_root_for_sacd( // Known and unchanged: no file I/O at all. let facts = image_facts(path); - match db.sacd_image_unchanged(&path_string, facts.size_bytes, facts.modified_ns) { + match db.observe_sacd_image( + root_id, + observed_generation, + &path_string, + facts.size_bytes, + facts.modified_ns, + SACD_PARSER_REVISION, + ) { Ok(true) => { summary.unchanged = summary.unchanged.saturating_add(1); continue; } Ok(false) => {} Err(error) => { + traversal_complete = false; summary.failed.push((path_string, error.to_string())); continue; } } // Signature first: a DVD or a data ISO leaves here silently. - if !qbz_disc::sacd::is_sacd_image(path) { - summary.ignored = summary.ignored.saturating_add(1); - continue; + match qbz_disc::sacd::sniff_sacd_image(path) { + Ok(SacdSniff::Sacd) => {} + Ok(SacdSniff::NotSacd) => { + summary.ignored = summary.ignored.saturating_add(1); + match db.remove_sacd_image_at_path(&path_string) { + Ok(removed) => { + summary.removed = summary + .removed + .saturating_add(removed.min(u32::MAX as usize) as u32); + } + Err(error) => { + traversal_complete = false; + summary.failed.push((path_string, error.to_string())); + } + } + continue; + } + Err(error) => { + log::warn!("[sacd] scan: signed image has an invalid Master TOC: {error}"); + summary.failed.push((path_string, error.to_string())); + continue; + } } match build_image_rows(path, labels) { - Ok(rows) => match db.import_sacd_image(&rows.import) { + Ok(rows) => match db.import_sacd_image(root_id, observed_generation, &rows.import) { Ok(result) if result.stale => { summary.unchanged = summary.unchanged.saturating_add(1); } @@ -345,6 +385,19 @@ pub fn scan_root_for_sacd( } } } + if !cancel.load(Ordering::Acquire) && traversal_complete { + match db.finish_sacd_root_scan(root_id, observed_generation) { + Ok(removed) => { + summary.removed = summary + .removed + .saturating_add(removed.min(u32::MAX as usize) as u32); + summary.prune_authorized = true; + } + Err(error) => summary + .failed + .push((root.to_string_lossy().into_owned(), error.to_string())), + } + } summary } @@ -385,12 +438,21 @@ mod tests { let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); let root = temp.path().join("music"); std::fs::create_dir_all(root.join("Some DVD")).unwrap(); + let root_id = db + .add_folder_with_network_info(&root.to_string_lossy(), false, None) + .unwrap(); write_plain_iso(&root.join("Some DVD/movie.iso")); std::fs::write(root.join("not-an-image.iso"), b"just bytes").unwrap(); std::fs::write(root.join("song.flac"), b"").unwrap(); - let summary = - scan_root_for_sacd(&db, &root, &SacdLabels::default(), &AtomicBool::new(false)); + let summary = scan_root_for_sacd( + &db, + root_id, + 1, + &root, + &SacdLabels::default(), + &AtomicBool::new(false), + ); assert_eq!( summary, SacdScanSummary { @@ -398,11 +460,20 @@ mod tests { imported: 0, unchanged: 0, ignored: 2, + removed: 0, + prune_authorized: true, failed: Vec::new(), } ); assert!(!db - .sacd_image_unchanged(&root.join("Some DVD/movie.iso").to_string_lossy(), 0, 0) + .observe_sacd_image( + root_id, + 2, + &root.join("Some DVD/movie.iso").to_string_lossy(), + 0, + 0, + super::SACD_PARSER_REVISION, + ) .unwrap()); } @@ -411,8 +482,13 @@ mod tests { let temp = TempDir::new().unwrap(); let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); write_plain_iso(&temp.path().join("movie.iso")); + let root_id = db + .add_folder_with_network_info(&temp.path().to_string_lossy(), false, None) + .unwrap(); let summary = scan_root_for_sacd( &db, + root_id, + 1, temp.path(), &SacdLabels::default(), &AtomicBool::new(true), diff --git a/crates/qbz-library/src/scan.rs b/crates/qbz-library/src/scan.rs index 537a7dfd1..47e101308 100644 --- a/crates/qbz-library/src/scan.rs +++ b/crates/qbz-library/src/scan.rs @@ -1515,18 +1515,22 @@ fn scan_root( // or import rejection is a scan error like any unreadable file. let sacd = crate::sacd_scan::scan_root_for_sacd( db, + folder.id, + state.generation.min(i64::MAX as u64) as i64, Path::new(&folder.path), &crate::sacd_scan::SacdLabels::default(), cancel, ); - if sacd.candidates > 0 { + if sacd.candidates > 0 || sacd.removed > 0 || !sacd.failed.is_empty() { log::info!( - "[local-scan] root_id={} sacd candidates={} imported={} unchanged={} ignored={} failed={}", + "[local-scan] root_id={} sacd candidates={} imported={} unchanged={} ignored={} removed={} prune_authorized={} failed={}", folder.id, sacd.candidates, sacd.imported, sacd.unchanged, sacd.ignored, + sacd.removed, + sacd.prune_authorized, sacd.failed.len(), ); } diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index 1105f3bf7..f78ba6d85 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -772,7 +772,7 @@ Rectangle { function applyFilter(rows, selectedFilter) { var selected = selectedFilter || ({}) if (Object.keys(selected).length === 0) return rows - var qAny = selected.hires || selected.cd || selected.lossy + var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy var fAny = selected.flac || selected.alac || selected.ape || selected.wav || selected.mp3 || selected.aac || selected.other var sAny = selected.local || selected.offline || selected.plex @@ -782,41 +782,45 @@ Rectangle { for (var i = 0; i < rows.length; i++) { var r = rows[i] if (selected.favorite === true && !root.albumFavorite(r)) continue - var tier = (r.qualityTier || "").toLowerCase() - var fmt = (r.format || "").toLowerCase() - if (qAny) { - var qok = (selected.dsd && tier === "dsd") - || (selected.hires && (tier === "hires" || tier === "max")) - || (selected.cd && tier === "cd") - || (selected.lossy && (tier === "mp3" || tier === "lossy")) - if (!qok) continue - } - if (fAny) { - var fok = selected[fmt] === true - || (selected.other === true && known[fmt] !== 1) - if (!fok) continue - } - if (sAny) { - // The chips are keyed off the FOLDED word, which is why - // local_rows.rs keeps folding `qobuz_purchase` into "offline" - // — a purchased album IS a Qobuz download as far as this row - // of chips is concerned, and the badge reads `sourceRaw` - // instead of splitting the bucket. - // - // The normalisation below is the reference's §10-H bug kept - // closed: Tauri's three arms never mention `qobuz_purchase`, - // so a raw word reaching here matches no chip and ticking ANY - // source filter hides every purchased album, silently. - var sources = r.sources && r.sources.length > 0 - ? r.sources : [r.source || "local"] - var sourceMatches = false - for (var si = 0; si < sources.length; si++) { - if (selected[sourceBucket(sources[si])] === true) { - sourceMatches = true + if (qAny || fAny || sAny) { + // Test all active media sections against ONE physical copy. + // Independent aggregate arrays are not enough: a logical + // album can carry DSD on its purchased copy and FLAC on a + // different local copy. DSD+FLAC must not manufacture a + // parent whose filtered detail has zero tracks. + var variants = r.mediaVariants || [] + if (variants.length === 0) { + var sources = r.sources && r.sources.length > 0 + ? r.sources : [r.source || "local"] + variants = [] + for (var si = 0; si < sources.length; si++) { + variants.push({ "qualityTier": r.qualityTier || "", + "format": r.format || "", + "source": sources[si] }) + } + } + var mediaMatches = false + for (var vi = 0; vi < variants.length; vi++) { + var variant = variants[vi] + var tier = (variant.qualityTier || "").toLowerCase() + var fmt = (variant.format || "").toLowerCase() + var qok = !qAny + || (selected.dsd && tier === "dsd") + || (selected.hires && (tier === "hires" || tier === "max")) + || (selected.cd && tier === "cd") + || (selected.lossy && (tier === "mp3" || tier === "lossy")) + var fok = !fAny || selected[fmt] === true + || (selected.other === true && known[fmt] !== 1) + // `sourceBucket` folds qobuz_purchase into Offline while + // its raw word remains available to the gold badge. + var sok = !sAny + || selected[sourceBucket(variant.source)] === true + if (qok && fok && sok) { + mediaMatches = true break } } - if (!sourceMatches) continue + if (!mediaMatches) continue } out.push(r) } @@ -886,7 +890,7 @@ Rectangle { function artistMatchesFilter(artist, selected) { if (!selected || Object.keys(selected).length === 0) return true - var qAny = selected.hires || selected.cd || selected.lossy + var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy var fAny = selected.flac || selected.alac || selected.ape || selected.wav || selected.mp3 || selected.aac || selected.other var sAny = selected.local || selected.offline || selected.plex diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml index 4ca3d0f94..795253527 100644 --- a/crates/qbz-qt/qml/views/library/FeedGridCell.qml +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -52,11 +52,8 @@ Item { } } function scheduleMutableRestore() { - var expected = cell.identity - Qt.callLater(function () { - if (cell.identity === expected) - cell.restoreMutableBindings() - }) + mutableRestore.expectedIdentity = cell.identity + mutableRestore.restart() } onItemChanged: { // A popup is a window-overlay child; close it while the old loaded @@ -71,6 +68,19 @@ Item { width: 200 height: 246 + // Lifetime-bound counterpart to Qt.callLater: recycling may destroy this + // cell before a global deferred closure runs. + Timer { + id: mutableRestore + interval: 0 + repeat: false + property string expectedIdentity: "" + onTriggered: { + if (cell.identity === expectedIdentity) + cell.restoreMutableBindings() + } + } + Component { id: albumCardComp AlbumCard { diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml index 1a878479a..9fa227af3 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml @@ -455,16 +455,29 @@ Item { }) } function scheduleMutableRestore() { - var expected = cardCell.slot - Qt.callLater(function () { - if (cardCell.slot === expected) - cardCell.restoreMutableBindings() - }) + mutableRestore.expected = cardCell.slot + mutableRestore.restart() } onSlotChanged: cardCell.scheduleMutableRestore() Component.onCompleted: cardCell.scheduleMutableRestore() + // A queued Qt.callLater closure can outlive this + // recycled delegate and then call into an invalid + // QML context. A child timer is cancelled with its + // owner, so delayed binding repair cannot escape + // the card cell's lifetime. + Timer { + id: mutableRestore + interval: 0 + repeat: false + property var expected: null + onTriggered: { + if (cardCell.slot === expected) + cardCell.restoreMutableBindings() + } + } + AlbumCard { id: albumCard localMode: true diff --git a/crates/qbz-qt/qml/views/local/LocalGenresTab.qml b/crates/qbz-qt/qml/views/local/LocalGenresTab.qml index e4ef280ca..0ce85ac5a 100644 --- a/crates/qbz-qt/qml/views/local/LocalGenresTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalGenresTab.qml @@ -118,6 +118,7 @@ Item { var tr = QbzSession.trRev var filters = [ { key: "favorite", label: QbzSession.tr("Favorites only", tr) }, + { key: "dsd", label: "DSD" }, { key: "hires", label: QbzSession.tr("Hi-Res", tr) }, { key: "cd", label: QbzSession.tr("CD", tr) }, { key: "lossy", label: QbzSession.tr("Lossy", tr) }, diff --git a/crates/qbz-qt/src/album_quick_view_qt.rs b/crates/qbz-qt/src/album_quick_view_qt.rs index 3a8e3195a..709c946a9 100644 --- a/crates/qbz-qt/src/album_quick_view_qt.rs +++ b/crates/qbz-qt/src/album_quick_view_qt.rs @@ -174,22 +174,7 @@ fn map_album(requested_id: String, album: Album) -> QuickViewDoc { } fn local_quality_rank(track: &LocalTrack) -> (u8, u32, u64) { - let tier = match crate::local_rows::tier_of(&track.format, track.bit_depth, track.sample_rate) { - "max" => 4, - "hires" => 3, - "cd" => 2, - "mp3" => 1, - _ => 0, - }; - ( - tier, - track.bit_depth.unwrap_or(0), - if track.sample_rate.is_finite() && track.sample_rate > 0.0 { - track.sample_rate as u64 - } else { - 0 - }, - ) + crate::local_rows::media_quality_rank(&track.format, track.bit_depth, track.sample_rate) } /// Build a preview from the best physical copy without calling diff --git a/crates/qbz-qt/src/disc_meta_qt.rs b/crates/qbz-qt/src/disc_meta_qt.rs index 76fa8ad70..aaf61c0bc 100644 --- a/crates/qbz-qt/src/disc_meta_qt.rs +++ b/crates/qbz-qt/src/disc_meta_qt.rs @@ -397,9 +397,6 @@ pub fn apply() { edited: true, }; qbz_disc::store::put_user(&identity.fingerprint, identity.disc_id.as_deref(), &memory); - if identity.kind == crate::disc_identity::DiscKind::Sacd { - crate::sacd_qt::persist_current_session(&identity.fingerprint); - } log::info!( "[qbz-qt] disc meta: applied {:?} by {:?} from {}", meta.title, @@ -419,9 +416,6 @@ pub fn apply() { if let Some(art) = crate::cdda_qt::fetch_cover_for(&release, None).await { qbz_disc::store::set_cover(&fingerprint, &art); crate::local_ephemeral::set_session_artwork(&art); - if identity.kind == crate::disc_identity::DiscKind::Sacd { - crate::sacd_qt::persist_current_session(&fingerprint); - } } }); } diff --git a/crates/qbz-qt/src/local_album_actions.rs b/crates/qbz-qt/src/local_album_actions.rs index 2a2140764..65328b60e 100644 --- a/crates/qbz-qt/src/local_album_actions.rs +++ b/crates/qbz-qt/src/local_album_actions.rs @@ -30,15 +30,17 @@ use cxx_qt_lib::QString; use qbz_app::settings::local_favorites::LocalFavItem; use qbz_app::shell::AppRuntime; use qbz_core::LoggingAdapter; -use qbz_library::{AudioFormat, LocalTrack}; +#[cfg(test)] +use qbz_library::AudioFormat; +use qbz_library::LocalTrack; use qbz_models::QueueTrack; use serde::Serialize; use crate::local_bridge::ui; use crate::local_playback::{fill_missing_covers, local_queue_track}; use crate::local_rows::{ - album_favorite_source, album_key, badge_source, badge_source_raw, map_track, tier_of, to_json, - total_duration, AlbumRow, TrackRow, + album_favorite_source, album_key, badge_source, badge_source_raw, map_track, + media_quality_rank, tier_of, to_json, total_duration, AlbumMediaVariant, AlbumRow, TrackRow, }; use crate::local_state::{state, with_art}; @@ -147,30 +149,9 @@ pub struct GenreAlbumDetailDoc { // Version splitting (local_library.rs `open_local_album` 1:1) // --------------------------------------------------------------------------- -/// Quality rank for ordering versions (hi-res first). +/// Shared quality rank for ordering versions (highest-quality first). fn version_rank(t: &LocalTrack) -> (u8, u32, u64) { - let lossless = matches!( - t.format, - AudioFormat::Flac - | AudioFormat::Alac - | AudioFormat::Wav - | AudioFormat::Aiff - | AudioFormat::Ape - | AudioFormat::Dsd - ); - let sample_rate = t.sample_rate.max(0.0) as u64; - let depth = t.bit_depth.unwrap_or(0); - let tier = if t.format == AudioFormat::Dsd || (lossless && (depth > 16 || sample_rate > 48_000)) - { - 3 - } else if lossless { - 2 - } else if t.format != AudioFormat::Unknown { - 1 - } else { - 0 - }; - (tier, depth, sample_rate) + media_quality_rank(&t.format, t.bit_depth, t.sample_rate) } /// Group the album's tracks by SOURCE DIRECTORY, sort each copy by @@ -218,8 +199,7 @@ fn version_info(tracks: &[LocalTrack]) -> AlbumVersion { track.disc_number.hash(&mut identity); track.track_number.hash(&mut identity); } - let detail = - crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate)); + let detail = crate::local_rows::detail_of(&t.format, t.bit_depth, t.sample_rate); let fmt = t.format.to_string(); let raw_source = badge_source_raw(t.source.as_deref()); AlbumVersion { @@ -492,8 +472,14 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow { } let best = tracks .iter() - .max_by_key(|t| t.bit_depth.unwrap_or(0)) + .max_by_key(|track| version_rank(track)) .unwrap_or(first); + let best_source_raw = badge_source_raw(best.source.as_deref()); + let best_source = if best_source_raw.is_empty() { + badge_source(best.source.as_deref()) + } else { + best_source_raw + }; let card = state(|s| { s.albums .iter() @@ -542,6 +528,11 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow { best.sample_rate, ), format: best.format.to_string(), + media_variants: vec![AlbumMediaVariant { + quality_tier: tier_of(&best.format, best.bit_depth, best.sample_rate).into(), + format: best.format.to_string(), + source: best_source, + }], genres: { let mut values = tracks .iter() @@ -1056,6 +1047,22 @@ mod version_tests { assert!(info.quality.contains("FLAC")); } + #[test] + fn dsd_version_is_preselected_ahead_of_pcm_hires() { + let mut tracks = copy("pcm-24-96", "user", "Rust in Peace", 9, 24); + let mut dsd = copy("purchased-dsd", "qobuz_purchase", "Rust in Peace", 9, 1); + for track in &mut dsd { + track.file_path = track.file_path.replace(".flac", ".iso"); + track.format = AudioFormat::Dsd; + track.sample_rate = 2_822_400.0; + } + tracks.extend(dsd); + + let versions = split_versions(tracks); + assert_eq!(versions[0].0, "purchased-dsd"); + assert_eq!(version_info(&versions[0].1).quality, "DSD64 · DSD"); + } + #[test] fn genres_detail_keeps_each_named_disc_and_its_own_cover() { let nonce = std::time::SystemTime::now() diff --git a/crates/qbz-qt/src/local_albums.rs b/crates/qbz-qt/src/local_albums.rs index b4a04869a..1075d85aa 100644 --- a/crates/qbz-qt/src/local_albums.rs +++ b/crates/qbz-qt/src/local_albums.rs @@ -16,7 +16,9 @@ use std::collections::{HashMap, HashSet}; use qbz_library::album_grouping::AlbumGroupMode; -use qbz_library::{AlbumTrackEvidence, AudioFormat, LocalAlbum, LocalTrack}; +#[cfg(test)] +use qbz_library::AudioFormat; +use qbz_library::{AlbumTrackEvidence, LocalAlbum, LocalTrack}; use crate::local_album_actions::AlbumDetailDoc; use crate::local_artist_match::{ @@ -24,8 +26,8 @@ use crate::local_artist_match::{ merge_artists, normalize_artist, AlbumCredit, ArtistInput, }; use crate::local_rows::{ - artist_key, badge_source, map_album, map_album_with_artists, map_track, tier_of, AlbumRow, - ArtistRow, LocalCounts, TrackRow, + album_media_variants, artist_key, badge_source, map_album, map_album_with_artists, map_track, + media_quality_rank, tier_of, AlbumMediaVariant, AlbumRow, ArtistRow, LocalCounts, TrackRow, }; use crate::local_state::{ commit_tracks_page, group_mode, state, tracks_generation, with_art, with_db, @@ -71,6 +73,14 @@ pub fn load_albums_blocking() -> Result, String> { let t_sql = t0.elapsed(); let identity_started = std::time::Instant::now(); let source_album_count = page.albums.len(); + // Preserve the physical tuples before coalescing chooses one visible + // representative. A logical card must match DSD when any of its versions + // is DSD, even when another copy owns the normal unfiltered badge. + let physical_media = page + .albums + .iter() + .map(|album| (album.id.clone(), album_media_variants(album))) + .collect::>(); let (albums, version_ids) = coalesce_album_versions(page.albums); let t_identity = identity_started.elapsed(); let total = albums.len() as u64; @@ -88,8 +98,15 @@ pub fn load_albums_blocking() -> Result, String> { albums .into_iter() .map(|a| { + let media_variants = logical_album_media_variants( + &a, + version_ids.get(&a.id).map(Vec::as_slice), + &physical_media, + ); let artists = album_credit_names(&a.artist, &a.all_artists, &aliases); - map_album_with_artists(a, art, artists) + let mut row = map_album_with_artists(a, art, artists); + row.media_variants = media_variants; + row }) .collect::>() }); @@ -333,30 +350,42 @@ fn coalesce_album_versions( } fn album_quality_rank(album: &LocalAlbum) -> (u8, u32, u64, u32) { - let lossless = matches!( - album.format, - AudioFormat::Flac - | AudioFormat::Alac - | AudioFormat::Wav - | AudioFormat::Aiff - | AudioFormat::Ape - | AudioFormat::Dsd - ); - let sample_rate = album.sample_rate.max(0.0) as u64; - let depth = album.bit_depth.unwrap_or(0); - let tier = - if album.format == AudioFormat::Dsd || (lossless && (depth > 16 || sample_rate > 48_000)) { - 3 - } else if lossless { - 2 - } else if album.format != AudioFormat::Unknown { - 1 - } else { - 0 - }; + let (tier, depth, sample_rate) = + media_quality_rank(&album.format, album.bit_depth, album.sample_rate); (tier, depth, sample_rate, album.track_count) } +/// Reattach every physical media tuple to the logical card selected by +/// `coalesce_album_versions`. `version_ids` is authoritative for a merged +/// card; the fallback covers a single source-native album. +fn logical_album_media_variants( + album: &LocalAlbum, + version_ids: Option<&[String]>, + physical_media: &HashMap>, +) -> Vec { + let mut variants = version_ids + .into_iter() + .flatten() + .filter_map(|id| physical_media.get(id)) + .flatten() + .cloned() + .collect::>(); + if variants.is_empty() { + variants = physical_media + .get(&album.id) + .cloned() + .unwrap_or_else(|| album_media_variants(album)); + } + variants.sort_by(|left, right| { + left.source + .cmp(&right.source) + .then_with(|| left.quality_tier.cmp(&right.quality_tier)) + .then_with(|| left.format.cmp(&right.format)) + }); + variants.dedup(); + variants +} + fn source_order(source: &str) -> u8 { match source { "user" | "local" => 0, @@ -1604,6 +1633,52 @@ mod phase_a_tests { assert_eq!(rows.len(), 2, "title and artist alone must never merge"); } + #[test] + fn logical_album_keeps_all_media_variants_and_selects_dsd_by_default() { + let tracks = [ + ("Holy Wars", 383), + ("Hangar 18", 311), + ("Take No Prisoners", 208), + ]; + let pcm = album_copy("pcm-24-96", "user", "Rust in Peace", &tracks, 24, 96_000.0); + let mut dsd = album_copy( + "purchased-dsd", + "qobuz_purchase", + "Rust in Peace", + &tracks, + 1, + 2_822_400.0, + ); + dsd.format = AudioFormat::Dsd; + let physical = [&pcm, &dsd] + .into_iter() + .map(|album| (album.id.clone(), album_media_variants(album))) + .collect::>(); + + let (rows, version_ids) = coalesce_album_versions(vec![pcm, dsd]); + assert_eq!(rows.len(), 1); + let logical = &rows[0]; + assert_eq!(logical.format, AudioFormat::Dsd); + assert_eq!( + tier_of(&logical.format, logical.bit_depth, logical.sample_rate), + "dsd" + ); + + let variants = logical_album_media_variants( + logical, + version_ids.get(&logical.id).map(Vec::as_slice), + &physical, + ); + assert!(variants.iter().any(|variant| { + variant.quality_tier == "dsd" + && variant.format == "DSD" + && variant.source == "qobuz_purchase" + })); + assert!(variants.iter().any(|variant| { + variant.quality_tier == "hires" && variant.format == "FLAC" && variant.source == "local" + })); + } + #[test] fn coverless_best_audio_copy_uses_the_best_available_group_cover() { let tracks = [("Eres Toda una Mujer", 209), ("Amar y Querer", 191)]; diff --git a/crates/qbz-qt/src/local_albums_model_qt.rs b/crates/qbz-qt/src/local_albums_model_qt.rs index 3a2038feb..95fab9148 100644 --- a/crates/qbz-qt/src/local_albums_model_qt.rs +++ b/crates/qbz-qt/src/local_albums_model_qt.rs @@ -17,7 +17,7 @@ use serde::Serialize; use crate::local_bridge::ui; use crate::local_rows::{ album_favorite_source, badge_source, badge_source_raw, catalog_album_sources, detail_of, - total_duration, AlbumRow, + total_duration, AlbumMediaVariant, AlbumRow, }; const PAGE_ENTRIES: usize = 100; @@ -1074,6 +1074,23 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum { let source = badge_source(Some(record.source_raw_or_kind())); let source_raw = badge_source_raw(Some(record.source_raw_or_kind())); let sources = catalog_album_sources(record); + let variant_sources = if sources.is_empty() { + vec![if source_raw.is_empty() { + source.clone() + } else { + source_raw.clone() + }] + } else { + sources.clone() + }; + let media_variants = variant_sources + .into_iter() + .map(|variant_source| AlbumMediaVariant { + quality_tier: record.quality_tier.clone(), + format: record.format.to_ascii_uppercase(), + source: variant_source, + }) + .collect(); let favoriteable = album_favorite_source(&sources).is_some(); NativeAlbum { row: AlbumRow { @@ -1094,6 +1111,7 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum { record.sample_rate_hz.unwrap_or(0) as f64, ), format: record.format.to_ascii_uppercase(), + media_variants, genres: Vec::new(), art_key, source, diff --git a/crates/qbz-qt/src/local_artists_model_qt.rs b/crates/qbz-qt/src/local_artists_model_qt.rs index 0bd5e92ae..0767c1d04 100644 --- a/crates/qbz-qt/src/local_artists_model_qt.rs +++ b/crates/qbz-qt/src/local_artists_model_qt.rs @@ -16,8 +16,8 @@ use serde::Serialize; use crate::local_bridge::ui; use crate::local_rows::{ - badge_source, badge_source_raw, catalog_album_sources, detail_of, total_duration, AlbumRow, - ArtistRow, + badge_source, badge_source_raw, catalog_album_sources, detail_of, total_duration, + AlbumMediaVariant, AlbumRow, ArtistRow, }; const PAGE_ENTRIES: usize = 100; @@ -1383,6 +1383,23 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum { let source = badge_source(Some(source_word(record))); let source_raw = badge_source_raw(Some(source_word(record))); let sources = catalog_album_sources(record); + let variant_sources = if sources.is_empty() { + vec![if source_raw.is_empty() { + source.clone() + } else { + source_raw.clone() + }] + } else { + sources.clone() + }; + let media_variants = variant_sources + .into_iter() + .map(|variant_source| AlbumMediaVariant { + quality_tier: record.quality_tier.clone(), + format: record.format.to_ascii_uppercase(), + source: variant_source, + }) + .collect(); let favoriteable = crate::local_rows::album_favorite_source(&sources).is_some(); NativeAlbum { row: AlbumRow { @@ -1403,6 +1420,7 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum { record.sample_rate_hz.unwrap_or(0) as f64, ), format: record.format.to_ascii_uppercase(), + media_variants, genres: Vec::new(), art_key, source, diff --git a/crates/qbz-qt/src/local_ephemeral.rs b/crates/qbz-qt/src/local_ephemeral.rs index 5400e9fe0..1c0377866 100644 --- a/crates/qbz-qt/src/local_ephemeral.rs +++ b/crates/qbz-qt/src/local_ephemeral.rs @@ -1,9 +1,9 @@ //! Ephemeral folders and live disc sessions. //! -//! A folder opened outside the indexed library and a physical CD never land a -//! row in `library.db`. A parsed SACD is the deliberate exception: `sacd_qt` -//! adopts its virtual tracks into the persistent catalogue first, then uses -//! this same in-memory pane for the immediate open experience. +//! A folder opened outside the indexed library, a physical CD and a manually +//! opened SACD image never land a row in `library.db`. SACD images become +//! persistent only when the regular folder scanner finds them below a +//! registered Local Library root. //! //! Qt/QML port of the shipping Slint pair (`crates/qbz/src/ephemeral.rs` + //! the `EphemeralPane` arm of `crates/qbz/src/local_library.rs`). ADR-006: @@ -635,7 +635,7 @@ pub fn open_path(path: String) { } /// Publish a session built from a track list rather than a directory scan — a -/// physical CD or an already catalogue-adopted SACD image. +/// physical CD or a manually opened SACD image. /// /// It goes through the SAME `publish_doc` as a folder, so the pane, the tab, /// the label and the open sequence all behave identically: a disc is not a @@ -644,8 +644,8 @@ pub fn open_path(path: String) { /// /// This SESSION is not rehydrated. Re-opening a physical drive at boot could /// find a different CD (or none), so it deliberately starts clean. SACD -/// catalogue persistence is separate and keyed by its TOC fingerprint; the -/// user still explicitly opens an image to create this live pane. +/// library indexing is a separate folder-scan concern; the user still +/// explicitly opens an image to create this live pane. pub fn adopt_tracks(label: &str, tracks: Vec) { let label = label.to_string(); // Taken HERE, on the reader's own thread, so it overlaps the reader's diff --git a/crates/qbz-qt/src/local_rows.rs b/crates/qbz-qt/src/local_rows.rs index 275f8749c..8a3f5a8ad 100644 --- a/crates/qbz-qt/src/local_rows.rs +++ b/crates/qbz-qt/src/local_rows.rs @@ -31,6 +31,18 @@ use serde::Serialize; // Rows (the QML contract) // --------------------------------------------------------------------------- +/// One physical media variant represented by a logical album row. Keeping +/// source, format and quality together is load-bearing: the filter sections +/// are AND-ed, so three independent aggregate arrays could claim a match by +/// taking DSD from one copy and FLAC/local from another. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct AlbumMediaVariant { + #[serde(rename = "qualityTier")] + pub quality_tier: String, + pub format: String, + pub source: String, +} + /// One album card (Albums tab + Folders tab flat mode). `id` is the group key /// of the ACTIVE identity mode — folder or metadata — so the detail query can /// round-trip it. Plex albums carry the content-hash key `plex:`. @@ -52,6 +64,11 @@ pub struct AlbumRow { #[serde(rename = "qualityDetail")] pub quality_detail: String, pub format: String, + /// Every physical quality/format/source combination represented by this + /// logical card. The scalar fields above remain the default/highest + /// quality version used by the visible badge. + #[serde(rename = "mediaVariants")] + pub media_variants: Vec, /// Every genre represented by this logical album, across all versions. pub genres: Vec, #[serde(rename = "artKey")] @@ -226,6 +243,45 @@ pub fn total_duration(secs: u64) -> String { } } +/// Stable ordering for physical versions. DSD is its own top-level tier: +/// treating its nominal one-bit depth as ordinary PCM made DSD64 lose to +/// 24/96 even though the UI and version picker expose DSD as the premium +/// representation. Within a tier, depth then rate preserve the existing PCM +/// ordering; within DSD, the common depth is equal so the DSD rate decides. +pub fn media_quality_rank( + format: &AudioFormat, + bit_depth: Option, + sample_rate_hz: f64, +) -> (u8, u32, u64) { + let sample_rate = if sample_rate_hz.is_finite() && sample_rate_hz > 0.0 { + sample_rate_hz as u64 + } else { + 0 + }; + let depth = bit_depth.unwrap_or(0); + if matches!(format, AudioFormat::Dsd) { + return (4, depth, sample_rate); + } + let lossless = matches!( + format, + AudioFormat::Flac + | AudioFormat::Alac + | AudioFormat::Wav + | AudioFormat::Aiff + | AudioFormat::Ape + ); + let tier = if lossless && (depth > 16 || sample_rate > 48_000) { + 3 + } else if lossless { + 2 + } else if !matches!(format, AudioFormat::Unknown) { + 1 + } else { + 0 + }; + (tier, depth, sample_rate) +} + /// Tier for the shared QualityBadge, mirroring its own derivation order /// (MP3 first, then max / hires / cd) so a local card and a Qobuz card can /// never disagree. @@ -269,6 +325,43 @@ pub fn detail_of(format: &AudioFormat, bit_depth: Option, sample_rate_hz: f crate::home_qt::quality_detail_from_parts(bit_depth, Some(sample_rate_hz)) } +/// Physical filter variants for one source-native album record. Source words +/// are projected through the same badge vocabulary as `AlbumRow.sources`, so +/// a scanned Qobuz purchase remains in the Offline bucket while preserving +/// its gold `qobuz_purchase` identity. +pub fn album_media_variants(album: &LocalAlbum) -> Vec { + let quality_tier = tier_of(&album.format, album.bit_depth, album.sample_rate).to_string(); + let format = album.format.to_string(); + let source_words = if album.sources.is_empty() { + vec![album.source.as_str()] + } else { + album.sources.iter().map(String::as_str).collect() + }; + let mut variants = source_words + .into_iter() + .map(|source| { + let raw = badge_source_raw(Some(source)); + AlbumMediaVariant { + quality_tier: quality_tier.clone(), + format: format.clone(), + source: if raw.is_empty() { + badge_source(Some(source)) + } else { + raw + }, + } + }) + .collect::>(); + variants.sort_by(|left, right| { + left.source + .cmp(&right.source) + .then_with(|| left.quality_tier.cmp(&right.quality_tier)) + .then_with(|| left.format.cmp(&right.format)) + }); + variants.dedup(); + variants +} + pub fn basename(path: &str) -> String { std::path::Path::new(path) .file_name() @@ -493,6 +586,7 @@ pub fn map_album_with_artists( art: &mut HashMap, artists: Vec, ) -> AlbumRow { + let media_variants = album_media_variants(&a); let key = album_key(&a.id); if let Some(p) = a.artwork_path.as_ref().filter(|p| !p.is_empty()) { let artwork_source = a.artwork_source.as_deref().unwrap_or(a.source.as_str()); @@ -533,6 +627,7 @@ pub fn map_album_with_artists( quality_tier: tier_of(&a.format, a.bit_depth, a.sample_rate).into(), quality_detail: detail_of(&a.format, a.bit_depth, a.sample_rate), format: a.format.to_string(), + media_variants, genres: a.genres, year: a.year.map(|y| y.to_string()).unwrap_or_default(), duration: total_duration(a.total_duration_secs), diff --git a/crates/qbz-qt/src/local_state.rs b/crates/qbz-qt/src/local_state.rs index 1a10dd0a6..4cdd2dc5f 100644 --- a/crates/qbz-qt/src/local_state.rs +++ b/crates/qbz-qt/src/local_state.rs @@ -747,6 +747,20 @@ mod phase_a_tests { assert!(!view_qml.contains("readonly property var tracksVisible: {")); } + #[test] + fn recycled_card_binding_repairs_are_bound_to_delegate_lifetimes() { + let local = include_str!("../qml/views/local/LocalAlbumCollection.qml"); + let feed = include_str!("../qml/views/library/FeedGridCell.qml"); + assert!(local.contains("id: mutableRestore")); + assert!(local.contains("mutableRestore.restart()")); + assert!(!local.contains( + "Qt.callLater(function () {\n if (cardCell.slot" + )); + assert!(feed.contains("id: mutableRestore")); + assert!(feed.contains("mutableRestore.restart()")); + assert!(!feed.contains("Qt.callLater(function () {\n if (cell.identity")); + } + #[test] fn genres_details_virtualizes_the_nested_box_set_rows() { // The outer ListView is album-granular: a 150-track box is one visible @@ -814,6 +828,31 @@ mod phase_a_tests { assert!(album_row.contains("id: rowMenuLoader\n active: false")); } + #[test] + fn dsd_filter_prunes_explorer_parent_rows_and_remains_visible_when_collapsed() { + let view = include_str!("../qml/views/LocalLibraryView.qml"); + let explorer = include_str!("../qml/views/local/LocalGenresTab.qml"); + + // Both parent projections use this gate: albums in Library Explorer + // and artists in the Artists tab. If DSD is omitted here, their tier + // comparison never runs even though the track/detail backend filters. + assert_eq!( + view.matches( + "var qAny = selected.dsd || selected.hires || selected.cd || selected.lossy", + ) + .count(), + 2 + ); + assert!(view.contains("(selected.dsd && tier === \"dsd\")")); + assert!(view.contains("(selected.dsd && value === \"dsd\")")); + assert!(view.contains("var variants = r.mediaVariants || []")); + assert!(view.contains("if (qok && fok && sok)")); + + // Collapsing the facet browser must not make the active DSD filter + // invisible or impossible to clear from its applied-filter strip. + assert!(explorer.contains("{ key: \"dsd\", label: \"DSD\" }")); + } + #[test] fn explicit_ephemeral_open_routes_globally_and_keeps_a_loading_surface() { let router = include_str!("../qml/shell/ContentRouter.qml"); diff --git a/crates/qbz-qt/src/sacd_qt.rs b/crates/qbz-qt/src/sacd_qt.rs index 842066d2e..7dd3eb7f8 100644 --- a/crates/qbz-qt/src/sacd_qt.rs +++ b/crates/qbz-qt/src/sacd_qt.rs @@ -1,19 +1,16 @@ -//! Open a SACD disc image and adopt its virtual tracks into the local library. +//! Open a SACD disc image as an in-memory ephemeral session. //! //! Unlike a CD, a SACD names itself: the Master TOC carries the album title //! and artist, and the area TOC carries a title per track. So this asks no //! network and invents nothing — every string below came off the disc. //! -//! Scope, and it is stated in the UI rather than hidden: the STEREO area of an -//! uncompressed image. A multichannel-only disc, a DST-compressed area or an -//! image with no ISO 9660 layer is REPORTED, never approximated into silence. +//! Scope: the stereo DSD64 area of a raw Scarlet Book or hybrid image, either +//! flat or DST-compressed. A multichannel-only, malformed or unsupported image +//! is reported, never approximated into silence. //! //! The rows themselves come from `qbz_library::sacd_scan::build_image_rows`, -//! the same builder the folder scan uses for the images it finds; this file -//! only adds what a manual open needs — the session identity, the ephemeral -//! session and the translated fallback labels. - -use qbz_library::sacd_scan::{image_facts, observation_token}; +//! the same parser/row builder the folder scan uses. Catalogue ownership does +//! not: only the folder scanner may import those rows into `library.db`. use crate::local_ephemeral; @@ -30,11 +27,12 @@ pub fn open_image(path: &std::path::Path) -> Result { qbz_disc::sacd::SacdError::NoStereoArea => { qbz_i18n::t("That image has no stereo audio area.") } - qbz_disc::sacd::SacdError::Dst => { - qbz_i18n::t("This disc is DST-compressed, which QBZ cannot play yet.") + qbz_disc::sacd::SacdError::MissingMasterToc + | qbz_disc::sacd::SacdError::NotRegularFile(_) => { + qbz_i18n::t("That file is not a SACD image.") } - qbz_disc::sacd::SacdError::Iso(_) => qbz_i18n::t("That file is not a disc image."), - other => format!("{other}"), + qbz_disc::sacd::SacdError::Iso(_) => qbz_i18n::t("Could not read the disc."), + _ => qbz_i18n::t("That SACD image is damaged or unsupported."), } })?; @@ -55,75 +53,10 @@ pub fn open_image(path: &std::path::Path) -> Result { rows.album ); - // The explicit Scarlet Book parse above is the discovery gate. Only a - // complete generation reaches the transaction; a missing/NAS-down image, - // malformed TOC or unsupported DST area leaves prior catalogue rows - // untouched and still falls back to the in-memory session behaviour. - persist(rows.import); - local_ephemeral::adopt_tracks(&rows.album, rows.tracks); Ok(count) } -/// Re-commit corrected naming/artwork from the currently open SACD without -/// blocking the Qt thread. The snapshot is taken before scheduling so a late -/// worker can never adopt rows from a newer session under the old fingerprint. -pub fn persist_current_session(fingerprint: &str) { - let tracks = crate::local_ephemeral::tracks_snapshot(); - let Some(image) = tracks - .first() - .and_then(|track| qbz_disc::SacdRef::parse(&track.file_path)) - .map(|reference| reference.image) - else { - return; - }; - if tracks.iter().any(|track| { - qbz_disc::SacdRef::parse(&track.file_path) - .map(|reference| reference.image != image) - .unwrap_or(true) - }) { - log::warn!("[qbz-qt] sacd: refusing a mixed-image session snapshot"); - return; - } - let facts = image_facts(&image); - let import = qbz_library::SacdImageImport { - fingerprint: fingerprint.to_string(), - image_path: image.to_string_lossy().into_owned(), - image_size_bytes: facts.size_bytes, - image_modified_ns: facts.modified_ns, - observed_at: observation_token(), - tracks, - }; - crate::spawn(async move { - let _ = tokio::task::spawn_blocking(move || persist(import)).await; - }); -} - -fn persist(import: qbz_library::SacdImageImport) -> bool { - let started = std::time::Instant::now(); - let Some(result) = crate::library_db_qt::with_db(true, |db| db.import_sacd_image(&import)) - else { - log::warn!("[qbz-qt] sacd: catalogue adoption failed; session remains available"); - return false; - }; - if result.stale { - log::debug!("[qbz-qt] sacd: stale catalogue snapshot ignored"); - return true; - } - log::info!( - "[qbz-qt] sacd: catalogue adopted inserted={} updated={} removed={} elapsed_ms={}", - result.inserted, - result.updated, - result.removed, - started.elapsed().as_millis() - ); - crate::local_catalog_qt::request_catch_up(); - // Native readers republish after catch-up; this also refreshes the - // contractually retained legacy fallback immediately. - crate::local_bridge_ops::reload_browse(); - true -} - #[cfg(test)] mod tests { #[test] diff --git a/flake.nix b/flake.nix index c6667830a..9d7397bcb 100644 --- a/flake.nix +++ b/flake.nix @@ -95,6 +95,7 @@ $out/share/icons/hicolor/"$size"x"$size"/apps/qbz.png done install -Dm644 $src/LICENSE $out/share/licenses/qbz/LICENSE + cp -r $src/licenses $out/share/licenses/qbz/third-party ''; meta = with pkgs.lib; { diff --git a/licenses/Apache-2.0-dst-decoder.txt b/licenses/Apache-2.0-dst-decoder.txt new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/licenses/Apache-2.0-dst-decoder.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/licenses/README.md b/licenses/README.md index c623edb59..9f93db82a 100644 --- a/licenses/README.md +++ b/licenses/README.md @@ -1,16 +1,16 @@ -# Third-party licences shipped with QBZ for Windows +# Third-party licences shipped with QBZ QBZ's own licence is in `LICENSE` at the repository root. This directory -carries the notices that must travel **with the binaries**, because the Windows -artifacts bundle their dependencies rather than resolving them from a system -package manager the way the Linux packages do. +carries the notices that must travel **with the binaries**. Every release +format installs or embeds this directory; some entries apply only to artifacts +that bundle the corresponding runtime. ## Qt 6 — LGPL v3 -The Windows MSI and the portable zip ship the Qt runtime. QBZ links against it -**dynamically** and does not modify it, which is what the LGPL requires for the -arrangement below; `LGPL-3.0.txt` is the full text, verbatim from -. +The Windows, macOS, AppImage, Flatpak and Snap artifacts ship the Qt runtime. +QBZ links against it **dynamically** and does not modify it, which is what the +LGPL requires for the arrangement below; `LGPL-3.0.txt` is the full text, +verbatim from . **Version:** Qt 6.9.3, `win64_msvc2022_64`, installed by [aqtinstall](https://github.com/miurahr/aqtinstall) in @@ -52,3 +52,17 @@ BSD and Unicode-DFS crates, all permissive and all requiring attribution rather than source availability. `cargo tree` and `Cargo.lock` are the authoritative list; the SBOM step in the release workflow is where a generated manifest belongs when that lands. + +### `dst-decoder` 0.1.2 + +SACD DST playback adds the pinned Rust crate `dst-decoder` 0.1.2. Its complete +Apache-2.0 license is bundled as `Apache-2.0-dst-decoder.txt`. Because upstream +states that its implementation is based heavily on the MPEG-4 DST reference +module, the complete provenance/copyright wording reproduced by upstream is +also bundled verbatim as `dst-decoder-upstream-NOTICE.txt`; the short +integration summary is `dst-decoder-NOTICE.md`. + +All release formats containing QBZ's DST decoder must install or otherwise +carry those three files. The crate is used only for conforming MPEG-4 DST +decoding. See the documented gate in +`qbz-nix-docs/qt-frontend/2026-09-04-sacd-raw-dst/01-DECODER-GATE.md`. diff --git a/licenses/dst-decoder-NOTICE.md b/licenses/dst-decoder-NOTICE.md new file mode 100644 index 000000000..f2886ee6c --- /dev/null +++ b/licenses/dst-decoder-NOTICE.md @@ -0,0 +1,11 @@ +# dst-decoder 0.1.2 notice + +QBZ uses `dst-decoder` 0.1.2 solely to decode MPEG-4 Direct Stream Transfer +(DST) audio. The Rust crate declares Apache License 2.0; its license text is at +. + +The upstream project states that it is based heavily on the MPEG-4 DST +reference implementation. QBZ therefore bundles both +`Apache-2.0-dst-decoder.txt` and the exact upstream provenance/copyright text +in `dst-decoder-upstream-NOTICE.txt`; neither depends on a live URL at install +time. diff --git a/licenses/dst-decoder-upstream-NOTICE.txt b/licenses/dst-decoder-upstream-NOTICE.txt new file mode 100644 index 000000000..e8a84f559 --- /dev/null +++ b/licenses/dst-decoder-upstream-NOTICE.txt @@ -0,0 +1,55 @@ +Upstream provenance notice reproduced verbatim from dst-decoder 0.1.2 README: + +The only information I have on this codec is the above, plus the following from one of the file headers, which is reproduced for the sake of crediting the original authors: + +``` +Lossless coding of 1-bit oversampled audio - DST (Direct Stream Transfer) + +This software was originally developed by: + +* Aad Rijnberg + Philips Digital Systems Laboratories Eindhoven + + +* Fons Bruekers + Philips Research Laboratories Eindhoven + + +* Eric Knapen + Philips Digital Systems Laboratories Eindhoven + + +And edited by: + +* Richard Theelen + Philips Digital Systems Laboratories Eindhoven + + +in the course of development of the MPEG-4 Audio standard ISO-14496-1, 2 and 3. +This software module is an implementation of a part of one or more MPEG-4 Audio +tools as specified by the MPEG-4 Audio standard. ISO/IEC gives users of the +MPEG-4 Audio standards free licence to this software module or modifications +thereof for use in hardware or software products claiming conformance to the +MPEG-4 Audio standards. Those intending to use this software module in hardware +or software products are advised that this use may infringe existing patents. +The original developers of this software of this module and their company, +the subsequent editors and their companies, and ISO/EIC have no liability for +use of this software module or modifications thereof in an implementation. +Copyright is not released for non MPEG-4 Audio conforming products. The +original developer retains full right to use this code for his/her own purpose, +assign or donate the code to a third party and to inhibit third party from +using the code for non MPEG-4 Audio conforming products. This copyright notice +must be included in all copies of derivative works. + +Copyright © 2004. + +Source file: dst_ac.c (Arithmetic Coding part of the DST Coding) + +Required libraries: + +Authors: +RT: Richard Theelen, PDSL-labs Eindhoven + +Changes: +08-Mar-2004 RT Initial version +``` diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index a252b9ef2..77622d0f7 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -52,4 +52,5 @@ package() { install -Dm644 packaging/arch/com.blitzfc.qbz.desktop "${pkgdir}/usr/share/applications/com.blitzfc.qbz.desktop" install -Dm644 packaging/icons/128x128.png "${pkgdir}/usr/share/icons/hicolor/128x128/apps/com.blitzfc.qbz.png" install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party" } diff --git a/packaging/aur/qbz-bin/PKGBUILD b/packaging/aur/qbz-bin/PKGBUILD index 862a90f24..d7bd8faf8 100644 --- a/packaging/aur/qbz-bin/PKGBUILD +++ b/packaging/aur/qbz-bin/PKGBUILD @@ -41,4 +41,5 @@ package() { install -Dm644 com.blitzfc.qbz.metainfo.xml \ "${pkgdir}/usr/share/metainfo/com.blitzfc.qbz.metainfo.xml" install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party" } diff --git a/packaging/aur/qbz/PKGBUILD b/packaging/aur/qbz/PKGBUILD index e84a7b553..01bb03d40 100644 --- a/packaging/aur/qbz/PKGBUILD +++ b/packaging/aur/qbz/PKGBUILD @@ -62,4 +62,5 @@ package() { install -Dm644 packaging/flatpak/com.blitzfc.qbz.metainfo.xml \ "${pkgdir}/usr/share/metainfo/com.blitzfc.qbz.metainfo.xml" install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party" } diff --git a/packaging/aur/qbzd-bin/PKGBUILD b/packaging/aur/qbzd-bin/PKGBUILD index b41d8aca4..002bca3ef 100644 --- a/packaging/aur/qbzd-bin/PKGBUILD +++ b/packaging/aur/qbzd-bin/PKGBUILD @@ -37,4 +37,5 @@ package() { install -Dm644 completions/qbzd.fish "${pkgdir}/usr/share/fish/vendor_completions.d/qbzd.fish" install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md" install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party" } diff --git a/packaging/aur/qbzd/PKGBUILD b/packaging/aur/qbzd/PKGBUILD index 8dd0ef3cc..1e6d7d31b 100644 --- a/packaging/aur/qbzd/PKGBUILD +++ b/packaging/aur/qbzd/PKGBUILD @@ -62,4 +62,5 @@ package() { install -Dm644 packaging/linux/qbzd-standalone-README.md \ "${pkgdir}/usr/share/doc/${pkgname}/README.md" install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party" } diff --git a/packaging/flatpak/com.blitzfc.qbz.yml b/packaging/flatpak/com.blitzfc.qbz.yml index 297bb4378..73e583d11 100644 --- a/packaging/flatpak/com.blitzfc.qbz.yml +++ b/packaging/flatpak/com.blitzfc.qbz.yml @@ -71,6 +71,7 @@ modules: - install -Dm644 payload/qbz.desktop /app/share/applications/com.blitzfc.qbz.desktop - install -Dm644 payload/com.blitzfc.qbz.metainfo.xml /app/share/metainfo/com.blitzfc.qbz.metainfo.xml - install -Dm644 payload/LICENSE /app/share/licenses/com.blitzfc.qbz/LICENSE + - cp -r payload/licenses /app/share/licenses/com.blitzfc.qbz/third-party - for size in 32 48 64 128 256 512; do install -Dm644 "payload/icons/${size}x${size}.png" "/app/share/icons/hicolor/${size}x${size}/apps/com.blitzfc.qbz.png"; done sources: diff --git a/packaging/gentoo/qbz-bin.ebuild.template b/packaging/gentoo/qbz-bin.ebuild.template index 6826a7b55..13e0f7dc2 100644 --- a/packaging/gentoo/qbz-bin.ebuild.template +++ b/packaging/gentoo/qbz-bin.ebuild.template @@ -72,6 +72,8 @@ src_install() { insinto /usr/share/metainfo doins com.blitzfc.qbz.metainfo.xml dodoc LICENSE + insinto /usr/share/licenses/${PN}/third-party + doins licenses/* } pkg_postinst() { diff --git a/packaging/gentoo/qbz.ebuild.template b/packaging/gentoo/qbz.ebuild.template index 4b4ec764e..18970f52f 100644 --- a/packaging/gentoo/qbz.ebuild.template +++ b/packaging/gentoo/qbz.ebuild.template @@ -68,6 +68,8 @@ src_install() { insinto /usr/share/metainfo doins packaging/flatpak/com.blitzfc.qbz.metainfo.xml dodoc LICENSE + insinto /usr/share/licenses/${PN}/third-party + doins licenses/* } pkg_postinst() { diff --git a/packaging/gentoo/qbzd-bin.ebuild.template b/packaging/gentoo/qbzd-bin.ebuild.template index 85e8e2b5b..ec89f4e1e 100644 --- a/packaging/gentoo/qbzd-bin.ebuild.template +++ b/packaging/gentoo/qbzd-bin.ebuild.template @@ -50,6 +50,8 @@ src_install() { insinto /usr/share/fish/vendor_completions.d newins "${dir}/completions/qbzd.fish" qbzd.fish dodoc "${dir}/README.md" "${dir}/LICENSE" + insinto /usr/share/licenses/${PN}/third-party + doins "${dir}"/licenses/* } pkg_postinst() { diff --git a/packaging/gentoo/qbzd.ebuild.template b/packaging/gentoo/qbzd.ebuild.template index a6fb00f52..981191d96 100644 --- a/packaging/gentoo/qbzd.ebuild.template +++ b/packaging/gentoo/qbzd.ebuild.template @@ -63,6 +63,8 @@ src_install() { insinto /usr/share/fish/vendor_completions.d newins "${T}/qbzd.fish" qbzd.fish dodoc packaging/linux/qbzd-standalone-README.md LICENSE + insinto /usr/share/licenses/${PN}/third-party + doins licenses/* } pkg_postinst() { diff --git a/packaging/nfpm/nfpm-qbzd.yaml b/packaging/nfpm/nfpm-qbzd.yaml index 46742604b..5b5bdf8f7 100644 --- a/packaging/nfpm/nfpm-qbzd.yaml +++ b/packaging/nfpm/nfpm-qbzd.yaml @@ -50,6 +50,9 @@ contents: dst: /usr/share/doc/qbzd/README.md - src: LICENSE dst: /usr/share/licenses/qbzd/LICENSE + - src: licenses + dst: /usr/share/licenses/qbzd/third-party + type: tree overrides: deb: diff --git a/packaging/nfpm/nfpm.yaml b/packaging/nfpm/nfpm.yaml index 139dd62b8..1b3b78614 100644 --- a/packaging/nfpm/nfpm.yaml +++ b/packaging/nfpm/nfpm.yaml @@ -57,6 +57,11 @@ contents: - src: ./AppDir/usr/qml dst: /opt/qbz/qml type: tree + - src: LICENSE + dst: /usr/share/licenses/qbz/LICENSE + - src: licenses + dst: /usr/share/licenses/qbz/third-party + type: tree - src: /opt/qbz/bin/qbz dst: /usr/bin/qbz type: symlink diff --git a/packaging/snap-qt/snapcraft-arm64.yaml b/packaging/snap-qt/snapcraft-arm64.yaml index 0af729eb8..d8f110e03 100644 --- a/packaging/snap-qt/snapcraft-arm64.yaml +++ b/packaging/snap-qt/snapcraft-arm64.yaml @@ -94,6 +94,8 @@ parts: qml: opt/qbz/qml qbz.desktop: usr/share/applications/qbz.desktop qbz.png: usr/share/icons/hicolor/256x256/apps/qbz.png + LICENSE: usr/share/licenses/qbz/LICENSE + licenses: usr/share/licenses/qbz/third-party # Runtime the AppDir does NOT carry. The AppDir contains Qt/QML, D-Bus and # the non-system xcb/Wayland helpers selected by linuxdeploy; the GNOME # extension supplies the host graphics, display and font stack. Keep this diff --git a/packaging/snap-qt/snapcraft.yaml b/packaging/snap-qt/snapcraft.yaml index 2904e6d3f..d13fb895f 100644 --- a/packaging/snap-qt/snapcraft.yaml +++ b/packaging/snap-qt/snapcraft.yaml @@ -94,6 +94,8 @@ parts: qml: opt/qbz/qml qbz.desktop: usr/share/applications/qbz.desktop qbz.png: usr/share/icons/hicolor/256x256/apps/qbz.png + LICENSE: usr/share/licenses/qbz/LICENSE + licenses: usr/share/licenses/qbz/third-party # Runtime the AppDir does NOT carry. The AppDir contains Qt/QML, D-Bus and # the non-system xcb/Wayland helpers selected by linuxdeploy; the GNOME # extension supplies the host graphics, display and font stack. Keep this diff --git a/scripts/packaging/test_aur_packages.sh b/scripts/packaging/test_aur_packages.sh index d0cb0f3f1..f5a4e89fa 100755 --- a/scripts/packaging/test_aur_packages.sh +++ b/scripts/packaging/test_aur_packages.sh @@ -58,6 +58,7 @@ install -m644 packaging/linux/qbz.desktop "$qbz_dir/qbz.desktop" install -m644 packaging/flatpak/com.blitzfc.qbz.metainfo.xml \ "$qbz_dir/com.blitzfc.qbz.metainfo.xml" install -m644 LICENSE "$qbz_dir/LICENSE" +cp -r licenses "$qbz_dir/licenses" for size in 32 48 64 128 256 512; do install -m644 "packaging/icons/${size}x${size}.png" \ "$qbz_dir/icons/hicolor/${size}x${size}/apps/qbz.png" @@ -71,6 +72,7 @@ install -m755 "$qbzd_test_bin" "$qbzd_dir/qbzd" install -m644 crates/qbzd/service/qbzd.service "$qbzd_dir/qbzd.service" install -m644 packaging/linux/qbzd-standalone-README.md "$qbzd_dir/README.md" install -m644 LICENSE "$qbzd_dir/LICENSE" +cp -r licenses "$qbzd_dir/licenses" "$qbzd_test_bin" completions bash > "$qbzd_dir/completions/qbzd.bash" "$qbzd_test_bin" completions zsh > "$qbzd_dir/completions/qbzd.zsh" "$qbzd_test_bin" completions fish > "$qbzd_dir/completions/qbzd.fish" @@ -111,6 +113,7 @@ for package in "${selected_packages[@]}"; do case "$PACKAGE" in qbz|qbz-bin) grep -aFq "QBZ/${VERSION}" /usr/bin/qbz + test -f /usr/share/licenses/${PACKAGE}/third-party/Apache-2.0-dst-decoder.txt set +e QT_QPA_PLATFORM=offscreen timeout 20 qbz >/tmp/qbz-smoke.log 2>&1 status=$? @@ -123,6 +126,7 @@ for package in "${selected_packages[@]}"; do ;; qbzd|qbzd-bin) qbzd version | grep -F "qbzd ${VERSION} " + test -f /usr/share/licenses/${PACKAGE}/third-party/Apache-2.0-dst-decoder.txt qbzd completions bash >/dev/null test -f /usr/lib/systemd/user/qbzd.service qbzd service systemd --user builder --bin /usr/bin/qbzd \