From 4bb49823129e603b03ce51823d25816690cfdf3e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:00:24 -0600 Subject: [PATCH] feat(sacd): support raw and DST-compressed ISO playback Parse resilient SACD images, decode DST, scan ISO releases into the local library, and keep explicit opens ephemeral. Ship decoder notices across distribution formats and align DSD album filtering and version selection. cx --- .github/workflows/build-qt-linux.yml | 4 + .github/workflows/release-flatpak.yml | 1 + .github/workflows/release-linux-aarch64.yml | 4 +- .github/workflows/release-linux.yml | 4 +- .github/workflows/release-macos.yml | 2 + .github/workflows/release-snap.yml | 6 + crates/Cargo.lock | 11 + crates/qbz-disc/Cargo.toml | 1 + crates/qbz-disc/src/sacd.rs | 1406 +++++++++++++++-- crates/qbz-dsd/Cargo.toml | 1 + crates/qbz-dsd/examples/sacd_verify.rs | 179 +++ crates/qbz-dsd/src/sacd_source.rs | 183 ++- .../translations/de/LC_MESSAGES/qbz-ui.po | 6 + .../translations/en/LC_MESSAGES/qbz-ui.po | 6 + .../translations/es/LC_MESSAGES/qbz-ui.po | 6 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 6 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 6 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 6 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 6 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 6 + crates/qbz-library/src/database.rs | 145 +- crates/qbz-library/src/lib.rs | 4 +- crates/qbz-library/src/sacd.rs | 592 ++++++- crates/qbz-library/src/sacd_scan.rs | 100 +- crates/qbz-library/src/scan.rs | 8 +- crates/qbz-qt/qml/views/LocalLibraryView.qml | 72 +- .../qbz-qt/qml/views/library/FeedGridCell.qml | 20 +- .../qml/views/local/LocalAlbumCollection.qml | 23 +- .../qbz-qt/qml/views/local/LocalGenresTab.qml | 1 + crates/qbz-qt/src/album_quick_view_qt.rs | 17 +- crates/qbz-qt/src/disc_meta_qt.rs | 6 - crates/qbz-qt/src/local_album_actions.rs | 65 +- crates/qbz-qt/src/local_albums.rs | 125 +- crates/qbz-qt/src/local_albums_model_qt.rs | 20 +- crates/qbz-qt/src/local_artists_model_qt.rs | 22 +- crates/qbz-qt/src/local_ephemeral.rs | 14 +- crates/qbz-qt/src/local_rows.rs | 95 ++ crates/qbz-qt/src/local_state.rs | 39 + crates/qbz-qt/src/sacd_qt.rs | 89 +- flake.nix | 1 + licenses/Apache-2.0-dst-decoder.txt | 201 +++ licenses/README.md | 30 +- licenses/dst-decoder-NOTICE.md | 11 + licenses/dst-decoder-upstream-NOTICE.txt | 55 + packaging/arch/PKGBUILD | 1 + packaging/aur/qbz-bin/PKGBUILD | 1 + packaging/aur/qbz/PKGBUILD | 1 + packaging/aur/qbzd-bin/PKGBUILD | 1 + packaging/aur/qbzd/PKGBUILD | 1 + packaging/flatpak/com.blitzfc.qbz.yml | 1 + packaging/gentoo/qbz-bin.ebuild.template | 2 + packaging/gentoo/qbz.ebuild.template | 2 + packaging/gentoo/qbzd-bin.ebuild.template | 2 + packaging/gentoo/qbzd.ebuild.template | 2 + packaging/nfpm/nfpm-qbzd.yaml | 3 + packaging/nfpm/nfpm.yaml | 5 + packaging/snap-qt/snapcraft-arm64.yaml | 2 + packaging/snap-qt/snapcraft.yaml | 2 + scripts/packaging/test_aur_packages.sh | 4 + 59 files changed, 3199 insertions(+), 436 deletions(-) create mode 100644 crates/qbz-dsd/examples/sacd_verify.rs create mode 100644 licenses/Apache-2.0-dst-decoder.txt create mode 100644 licenses/dst-decoder-NOTICE.md create mode 100644 licenses/dst-decoder-upstream-NOTICE.txt 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 \