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
This commit is contained in:
vicrodh committed 2026-09-05 10:00:24 -06:00
1 parent b4c706b828
commit 4bb4982312
59 files changed
+3199 -436

No files matched your search

+4
View File
@@ -228,11 +228,15 @@ jobs:
scripts/packaging/desktop_entry_check.sh --appdir AppDir scripts/packaging/desktop_entry_check.sh --appdir AppDir
# Nothing in the app uses Qt SQL / network-information plugins. # Nothing in the app uses Qt SQL / network-information plugins.
rm -rf AppDir/usr/plugins/sqldrivers AppDir/usr/plugins/networkinformation 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 ')" 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/libqwayland-generic.so
test -f AppDir/usr/plugins/platforms/libqoffscreen.so test -f AppDir/usr/plugins/platforms/libqoffscreen.so
test -f AppDir/usr/qml/QtQuick/Effects/libeffectsplugin.so test -f AppDir/usr/qml/QtQuick/Effects/libeffectsplugin.so
test -f AppDir/usr/bin/qt.conf 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 du -sh AppDir
# THE gate. Runs the AppDir with the Qt install removed from the # THE gate. Runs the AppDir with the Qt install removed from the
+1
View File
@@ -143,6 +143,7 @@ jobs:
cp packaging/flatpak/com.blitzfc.qbz.desktop "$P/qbz.desktop" cp packaging/flatpak/com.blitzfc.qbz.desktop "$P/qbz.desktop"
cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "$P/" cp packaging/flatpak/com.blitzfc.qbz.metainfo.xml "$P/"
cp LICENSE "$P/LICENSE" 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/" mkdir -p "$P/icons"; cp packaging/icons/{32x32,48x48,64x64,128x128,256x256,512x512}.png "$P/icons/"
ls -la "$P" ls -la "$P"
+3 -1
View File
@@ -10,7 +10,7 @@
# distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working. # distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working.
# #
# CONTRACT (do not break): the release must keep publishing # 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, # com.blitzfc.qbz.metainfo.xml, qbzd.service,
# completions/, icons/hicolor/...}) # completions/, icons/hicolor/...})
# QBZ_${VERSION}_arm64.deb / QBZ-${VERSION}-1.aarch64.rpm / QBZ_${VERSION}_arm64.AppImage # 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/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 packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml"
cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE" cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE"
cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses"
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd" cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd" chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service" 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 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 packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md"
cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE" 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}" tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
ls -la dist ls -la dist
+3 -1
View File
@@ -10,7 +10,7 @@
# distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working. # distros without a usable Qt (Ubuntu 24.04 = Qt 6.4) keep working.
# #
# CONTRACT (do not break): the release must keep publishing # 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, # com.blitzfc.qbz.metainfo.xml, qbzd.service,
# completions/, icons/hicolor/...}) # completions/, icons/hicolor/...})
# QBZ_${VERSION}_amd64.deb / QBZ-${VERSION}-1.x86_64.rpm / QBZ_${VERSION}_amd64.AppImage # 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/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 packaging/flatpak/com.blitzfc.qbz.metainfo.xml "tarball-staging/${TARBALL_DIR}/com.blitzfc.qbz.metainfo.xml"
cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE" cp LICENSE "tarball-staging/${TARBALL_DIR}/LICENSE"
cp -r licenses "tarball-staging/${TARBALL_DIR}/licenses"
cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd" cp qbzd "tarball-staging/${TARBALL_DIR}/qbzd"
chmod +x "tarball-staging/${TARBALL_DIR}/qbzd" chmod +x "tarball-staging/${TARBALL_DIR}/qbzd"
cp crates/qbzd/service/qbzd.service "tarball-staging/${TARBALL_DIR}/qbzd.service" 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 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 packaging/linux/qbzd-standalone-README.md "qbzd-tarball-staging/${QBZD_DIR}/README.md"
cp LICENSE "qbzd-tarball-staging/${QBZD_DIR}/LICENSE" 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}" tar -czvf "dist/qbzd-${VERSION}-linux-${DEB_ARCH}.tar.gz" -C qbzd-tarball-staging "${QBZD_DIR}"
ls -la dist ls -la dist
+2
View File
@@ -166,6 +166,8 @@ jobs:
cp "crates/target/${{ matrix.target }}/release/qbz" "$APP/Contents/MacOS/qbz" cp "crates/target/${{ matrix.target }}/release/qbz" "$APP/Contents/MacOS/qbz"
chmod +x "$APP/Contents/MacOS/qbz" chmod +x "$APP/Contents/MacOS/qbz"
cp packaging/icons/icon.icns "$APP/Contents/Resources/icon.icns" 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 # The Liquid Glass app icon (packaging/icons/QBZ.icon, Icon Composer
# source, issue #712): compiled by Xcode 26's actool into # source, issue #712): compiled by Xcode 26's actool into
# Assets.car + CFBundleIconName=QBZ. macOS 26 renders the layered # Assets.car + CFBundleIconName=QBZ. macOS 26 renders the layered
+6
View File
@@ -154,6 +154,9 @@ jobs:
cp -a AppDir/usr/bin AppDir/usr/lib AppDir/usr/plugins AppDir/usr/qml snap-payload/ 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.desktop snap-payload/qbz.desktop
cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png 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 mkdir -p snap
cp packaging/snap-qt/snapcraft.yaml snap/snapcraft.yaml cp packaging/snap-qt/snapcraft.yaml snap/snapcraft.yaml
sed -i "s/^version:.*/version: '${VER}'/" 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 -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.desktop snap-payload/qbz.desktop
cp packaging/snap/snap/gui/qbz.png snap-payload/qbz.png 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 mkdir -p snap
cp packaging/snap-qt/snapcraft-arm64.yaml snap/snapcraft.yaml cp packaging/snap-qt/snapcraft-arm64.yaml snap/snapcraft.yaml
sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml sed -i "s/^version:.*/version: '${VER}'/" snap/snapcraft.yaml
+11
View File
@@ -1493,6 +1493,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" 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]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@@ -4200,6 +4209,7 @@ version = "2.1.0"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"dirs 6.0.0", "dirs 6.0.0",
"encoding_rs",
"libc", "libc",
"log", "log",
"plist", "plist",
@@ -4217,6 +4227,7 @@ dependencies = [
name = "qbz-dsd" name = "qbz-dsd"
version = "2.1.0" version = "2.1.0"
dependencies = [ dependencies = [
"dst-decoder",
"id3", "id3",
"log", "log",
"qbz-disc", "qbz-disc",
+1
View File
@@ -15,6 +15,7 @@ rusqlite = { version = "0.40", features = ["bundled"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
dirs = "6" dirs = "6"
encoding_rs = "0.8"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2" libc = "0.2"
+1276 -130
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -10,3 +10,4 @@ qbz-disc = { path = "../qbz-disc" }
thiserror = { workspace = true } thiserror = { workspace = true }
log = { workspace = true } log = { workspace = true }
id3 = "1" id3 = "1"
dst-decoder = "=0.1.2"
+179
View File
@@ -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 -- <image.iso> [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<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let image = std::path::PathBuf::from(
args.next()
.ok_or("usage: sacd_verify <image.iso> [track]")?,
);
let selected = args.next().map(|value| value.parse::<u8>()).transpose()?;
if args.next().is_some() {
return Err("usage: sacd_verify <image.iso> [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::<Vec<_>>();
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(())
}
+151 -32
View File
@@ -5,7 +5,8 @@
//! The dependency runs qbz-dsd -> qbz-disc and never the other way: qbz-disc //! The dependency runs qbz-dsd -> qbz-disc and never the other way: qbz-disc
//! owns bytes and geometry, this file owns the audio contract. //! 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}; use crate::demux::{DsdDemuxer, DsdError, DsdStreamInfo, DsdTags};
@@ -15,10 +16,36 @@ const DSD64: u32 = 2_822_400;
const FRAME: usize = 9408; const FRAME: usize = 9408;
/// Bytes ONE channel contributes to a frame. /// Bytes ONE channel contributes to a frame.
const FRAME_PER_CH: usize = FRAME / 2; const FRAME_PER_CH: usize = FRAME / 2;
/// Sectors pulled from the image per refill. 200 sectors is ~0.57 s of audio /// Sectors pulled from the image per legacy diagnostic refill.
/// and keeps the read amortised without holding much.
const REFILL_SECTORS: usize = 200; const REFILL_SECTORS: usize = 200;
fn decode_dst_frame(
decoder: &mut DstDecoder,
scratch: &mut [u8],
payload: &[u8],
pending: &mut Vec<u8>,
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. /// How the two channels sit inside one frame.
/// ///
/// Both schemes exist in the wild — DFF interleaves per BYTE, DSF per block — /// Both schemes exist in the wild — DFF interleaves per BYTE, DSF per block —
@@ -71,15 +98,26 @@ pub enum ChannelLayout {
} }
pub struct SacdDemuxer { pub struct SacdDemuxer {
reader: SacdTrackReader, reader: SacdReader,
info: DsdStreamInfo, info: DsdStreamInfo,
layout: ChannelLayout, layout: ChannelLayout,
/// Audio payload read but not yet split into channels. Only whole frames /// Audio payload read but not yet split into channels. Only whole frames
/// are consumed, so a partial frame waits here for the next refill. /// are consumed, so a partial frame waits here for the next refill.
pending: Vec<u8>, pending: Vec<u8>,
dst_decoder: Option<DstDecoder>,
decode_scratch: Vec<u8>,
track_number: u8,
frame_number: u32,
done: bool, 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 { impl Default for ChannelLayout {
/// The measured layout. A caller that does not care gets the right one. /// The measured layout. A caller that does not care gets the right one.
fn default() -> Self { fn default() -> Self {
@@ -89,10 +127,7 @@ impl Default for ChannelLayout {
impl SacdDemuxer { impl SacdDemuxer {
/// Open with the measured layout. /// Open with the measured layout.
pub fn open_default( pub fn open_default(image: &std::path::Path, track: &SacdTrack) -> Result<Self, DsdError> {
image: &std::path::Path,
track: &SacdTrack,
) -> Result<Self, DsdError> {
Self::open(image, track, ChannelLayout::default()) Self::open(image, track, ChannelLayout::default())
} }
@@ -113,16 +148,29 @@ impl SacdDemuxer {
layout: ChannelLayout, layout: ChannelLayout,
sync: bool, sync: bool,
) -> Result<Self, DsdError> { ) -> Result<Self, DsdError> {
let reader = SacdTrackReader::open_with( let reader = if sync {
image, SacdReader::Framed(
track, SacdFrameReader::open(image, track)
if sync { .map_err(|error| DsdError::Corrupt(error.to_string()))?,
qbz_disc::sacd::FrameSync::ToFirstFrame )
} else { } else {
qbz_disc::sacd::FrameSync::FromPayloadStart SacdReader::Legacy(
}, SacdTrackReader::open_with(
) image,
.map_err(|e| DsdError::Corrupt(e.to_string()))?; 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 { Ok(Self {
reader, reader,
info: DsdStreamInfo { info: DsdStreamInfo {
@@ -131,7 +179,7 @@ impl SacdDemuxer {
// Bits per channel. The area TOC gives a duration, and DSD64 // Bits per channel. The area TOC gives a duration, and DSD64
// is exactly 2 822 400 bits/s, so this is the count rather // is exactly 2 822 400 bits/s, so this is the count rather
// than an estimate. // 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. // SACD is MSB-first, like DFF and unlike DSF's usual LSB-first.
// Getting this backwards does not fail — it plays, as noise. // Getting this backwards does not fail — it plays, as noise.
lsb_first: false, lsb_first: false,
@@ -143,6 +191,10 @@ impl SacdDemuxer {
}, },
layout, layout,
pending: Vec::new(), pending: Vec::new(),
dst_decoder,
decode_scratch: vec![0u8; FRAME],
track_number: track.number,
frame_number: 0,
done: false, done: false,
}) })
} }
@@ -151,19 +203,54 @@ impl SacdDemuxer {
if self.done { if self.done {
return Ok(()); return Ok(());
} }
let mut chunk = Vec::new(); match &mut self.reader {
match self.reader.next_chunk(&mut chunk, REFILL_SECTORS) { SacdReader::Legacy(reader) => {
// ZERO IS NOT THE END. While the reader is still hunting for the let mut chunk = Vec::new();
// first frame boundary it drops every audio byte it sees, so an reader
// empty chunk is an ordinary early read. Only the reader knows .next_chunk(&mut chunk, REFILL_SECTORS)
// whether the track is over, and it is asked. .map_err(|error| DsdError::Corrupt(error.to_string()))?;
Ok(_) => {
self.pending.extend_from_slice(&chunk); self.pending.extend_from_slice(&chunk);
if self.reader.finished() { self.done = reader.finished();
self.done = true;
}
} }
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(()) Ok(())
} }
@@ -176,9 +263,24 @@ impl DsdDemuxer for SacdDemuxer {
fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> { fn seek_to_bit(&mut self, bit_per_channel: u64) -> Result<(), DsdError> {
let target = bit_per_channel.min(self.info.sample_count); 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.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(()) Ok(())
} }
@@ -281,4 +383,21 @@ mod tests {
assert!(r.iter().all(|b| *b == 0x22), "channel 1 is the odd bytes"); 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"); 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"
);
}
} }
@@ -5430,6 +5430,12 @@ msgstr "Diese Disc ist DST-komprimiert; QBZ kann sie noch nicht abspielen."
msgid "That file is not a disc image." msgid "That file is not a disc image."
msgstr "Diese Datei ist kein 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" msgid "SACD"
msgstr "SACD" msgstr "SACD"
@@ -960,6 +960,12 @@ msgstr "This disc is DST-compressed, which QBZ cannot play yet."
msgid "That file is not a disc image." msgid "That file is not a disc image."
msgstr "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" msgid "SACD"
msgstr "SACD" msgstr "SACD"
@@ -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." msgid "That file is not a disc image."
msgstr "Ese archivo no es una imagen de disco." 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" msgid "SACD"
msgstr "SACD" msgstr "SACD"
@@ -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." msgid "That file is not a disc image."
msgstr "Ce fichier n'est pas une image disque." 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" msgid "SACD"
msgstr "SACD" msgstr "SACD"
@@ -5416,6 +5416,12 @@ msgstr "このディスクはDST圧縮のため、QBZではまだ再生できま
msgid "That file is not a disc image." msgid "That file is not a disc image."
msgstr "そのファイルはディスクイメージではありません。" msgstr "そのファイルはディスクイメージではありません。"
msgid "That file is not a SACD image."
msgstr "そのファイルはSACDイメージではありません。"
msgid "That SACD image is damaged or unsupported."
msgstr "そのSACDイメージは破損しているか、サポートされていません。"
msgid "SACD" msgid "SACD"
msgstr "SACD" msgstr "SACD"
@@ -5418,6 +5418,12 @@ msgstr "Deze schijf is DST-gecomprimeerd; QBZ kan die nog niet afspelen."
msgid "That file is not a disc image." msgid "That file is not a disc image."
msgstr "Dat bestand is geen schijfimage." 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" msgid "SACD"
msgstr "SACD" msgstr "SACD"
@@ -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." msgid "That file is not a disc image."
msgstr "Esse arquivo não é uma imagem de disco." 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" msgid "SACD"
msgstr "SACD" msgstr "SACD"
@@ -5447,6 +5447,12 @@ msgstr "Диск сжат в DST — QBZ пока не может его вос
msgid "That file is not a disc image." msgid "That file is not a disc image."
msgstr "Это не образ диска." msgstr "Это не образ диска."
msgid "That file is not a SACD image."
msgstr "Это не образ SACD."
msgid "That SACD image is damaged or unsupported."
msgstr "Этот образ SACD повреждён или не поддерживается."
msgid "SACD" msgid "SACD"
msgstr "SACD" msgstr "SACD"
+143 -2
View File
@@ -1,7 +1,9 @@
//! SQLite database layer for library persistence //! SQLite database layer for library persistence
use rusqlite::{params, Connection, OptionalExtension}; 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 std::time::Duration;
use crate::{ use crate::{
@@ -37,6 +39,19 @@ pub struct LibraryDatabase {
conn: Connection, 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<Mutex<HashSet<PathBuf>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
/// A SQL predicate restricting the shared remote mirror to the sources the user /// A SQL predicate restricting the shared remote mirror to the sources the user
/// has enabled. /// has enabled.
/// ///
@@ -68,6 +83,10 @@ impl LibraryDatabase {
conn.busy_timeout(Duration::from_millis(2_500)) conn.busy_timeout(Duration::from_millis(2_500))
.map_err(|e| LibraryError::Database(format!("Failed to set busy timeout: {}", e)))?; .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 // Enable WAL mode for better concurrent access
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| LibraryError::Database(format!("Failed to set WAL mode: {}", e)))?; .map_err(|e| LibraryError::Database(format!("Failed to set WAL mode: {}", e)))?;
@@ -75,6 +94,18 @@ impl LibraryDatabase {
let db = Self { conn }; let db = Self { conn };
db.init_schema()?; db.init_schema()?;
db.run_migrations()?; 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, // First-class LOCAL playlists (offline-mode D7) — separate module,
// same database file. Idempotent CREATE IF NOT EXISTS. // same database file. Idempotent CREATE IF NOT EXISTS.
crate::local_playlists::init_schema(&db.conn) crate::local_playlists::init_schema(&db.conn)
@@ -84,6 +115,7 @@ impl LibraryDatabase {
crate::qobuz_playlist_snapshot::init_schema(&db.conn).map_err(|e| { crate::qobuz_playlist_snapshot::init_schema(&db.conn).map_err(|e| {
LibraryError::Database(format!("qobuz_playlist_snapshot schema: {}", e)) LibraryError::Database(format!("qobuz_playlist_snapshot schema: {}", e))
})?; })?;
drop(schema_guard);
Ok(db) Ok(db)
} }
@@ -147,7 +179,8 @@ impl LibraryDatabase {
image_path TEXT NOT NULL UNIQUE, image_path TEXT NOT NULL UNIQUE,
image_size_bytes INTEGER NOT NULL, image_size_bytes INTEGER NOT NULL,
image_modified_ns 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 ( 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 CREATE INDEX IF NOT EXISTS idx_local_sacd_tracks_local_id
ON local_sacd_tracks(local_track_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; -- Incremental scanner state. `local_tracks` remains authoritative;
-- these tables only remember what each root observed and whether -- these tables only remember what each root observed and whether
-- a completed generation is allowed to prune stale rows. -- a completed generation is allowed to prune stale rows.
@@ -411,6 +457,42 @@ impl LibraryDatabase {
/// Run schema migrations for existing databases /// Run schema migrations for existing databases
fn run_migrations(&self) -> Result<(), LibraryError> { 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 // Migration: Add qobuz download tracking fields
let has_source: bool = self let has_source: bool = self
.conn .conn
@@ -1057,6 +1139,11 @@ impl LibraryDatabase {
.optional() .optional()
.map_err(|e| LibraryError::Database(e.to_string()))?; .map_err(|e| LibraryError::Database(e.to_string()))?;
if let Some(root_id) = root_id { 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 self.conn
.execute( .execute(
"DELETE FROM local_scan_files WHERE root_id = ?", "DELETE FROM local_scan_files WHERE root_id = ?",
@@ -1264,6 +1351,7 @@ impl LibraryDatabase {
params![id], params![id],
) )
.map_err(|e| LibraryError::Database(e.to_string()))?; .map_err(|e| LibraryError::Database(e.to_string()))?;
self.reconcile_sacd_catalog_scope()?;
Ok(()) Ok(())
} }
@@ -1613,6 +1701,7 @@ impl LibraryDatabase {
"BEGIN IMMEDIATE; "BEGIN IMMEDIATE;
DELETE FROM local_tracks WHERE source IS NULL OR source != 'qobuz_download'; DELETE FROM local_tracks WHERE source IS NULL OR source != 'qobuz_download';
DELETE FROM local_sacd_tracks; DELETE FROM local_sacd_tracks;
DELETE FROM local_sacd_roots;
DELETE FROM local_sacd_images; DELETE FROM local_sacd_images;
DELETE FROM local_scan_files; DELETE FROM local_scan_files;
DELETE FROM local_scan_cue_refs; DELETE FROM local_scan_cue_refs;
@@ -9021,3 +9110,55 @@ mod purchase_registry_reachability_tests {
crate::reachability::reset_cooldowns(); 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::<Vec<_>>();
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);
}
}
+3 -1
View File
@@ -71,7 +71,9 @@ pub use reachability::{
probe, probe_default, probe_file, probe_file_default, FileProbe, Reach, DEFAULT_PROBE, probe, probe_default, probe_file, probe_file_default, FileProbe, Reach, DEFAULT_PROBE,
}; };
pub use sacd::{SacdImageImport, SacdImportResult}; 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 scan::{scan_with_progress, ScanEvent};
pub use scanner::{ pub use scanner::{
LibraryScanner, ScanEntry, ScanFileKind, ScanResult, ScanStream, ScanWalkError, SymlinkPolicy, LibraryScanner, ScanEntry, ScanFileKind, ScanResult, ScanStream, ScanWalkError, SymlinkPolicy,
+553 -39
View File
@@ -7,6 +7,7 @@
//! prune the last known-good rows. //! prune the last known-good rows.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::Path;
use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior};
@@ -22,6 +23,8 @@ pub struct SacdImageImport {
pub image_size_bytes: u64, pub image_size_bytes: u64,
pub image_modified_ns: i64, pub image_modified_ns: i64,
pub observed_at: 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 /// Complete stereo-area track set. Each path must be
/// `sacd:<image_path>#<track_number>`. /// `sacd:<image_path>#<track_number>`.
pub tracks: Vec<LocalTrack>, pub tracks: Vec<LocalTrack>,
@@ -39,36 +42,65 @@ pub struct SacdImportResult {
} }
impl LibraryDatabase { impl LibraryDatabase {
/// Atomically adopt a fully parsed SACD image into the local library. /// Mark a known image as seen by one registered library root, and report
/// /// whether its file facts and parser revision still match. Unknown paths
/// Whether an image at this path was already imported with exactly this /// remain unowned until a complete parse is imported.
/// size and mtime — the folder scan's zero-I/O skip for a known disc. pub fn observe_sacd_image(
pub fn sacd_image_unchanged(
&self, &self,
root_id: i64,
observed_generation: i64,
image_path: &str, image_path: &str,
size_bytes: u64, size_bytes: u64,
modified_ns: i64, modified_ns: i64,
parser_revision: i64,
) -> Result<bool, LibraryError> { ) -> Result<bool, LibraryError> {
self.with_connection(|connection| { 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( .query_row(
"SELECT 1 FROM local_sacd_images "SELECT fingerprint,image_size_bytes,image_modified_ns,parser_revision
WHERE image_path=?1 AND image_size_bytes=?2 AND image_modified_ns=?3", FROM local_sacd_images WHERE image_path=?1",
params![image_path, to_i64(size_bytes), modified_ns], params![image_path],
|_| Ok(()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
))
},
) )
.optional() .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 /// The geometry fingerprint, not the file path, owns the row ids. Moving
/// an image and opening it again therefore preserves playlists/history /// an image and opening it again therefore preserves playlists/history
/// references. A successful shorter generation prunes obsolete virtual /// references. A successful shorter generation prunes obsolete virtual
/// tracks; validation or SQL failure rolls the whole operation back. /// tracks; validation or SQL failure rolls the whole operation back.
pub fn import_sacd_image( pub fn import_sacd_image(
&self, &self,
root_id: i64,
observed_generation: i64,
import: &SacdImageImport, import: &SacdImageImport,
) -> Result<SacdImportResult, LibraryError> { ) -> Result<SacdImportResult, LibraryError> {
let ordered = validate_import(import)?; let ordered = validate_import(import)?;
@@ -79,6 +111,7 @@ impl LibraryDatabase {
let transaction = let transaction =
Transaction::new_unchecked(connection, TransactionBehavior::Immediate) Transaction::new_unchecked(connection, TransactionBehavior::Immediate)
.map_err(database_error)?; .map_err(database_error)?;
ensure_registered_root_owns_path(&transaction, root_id, &import.image_path)?;
let latest_observation = transaction let latest_observation = transaction
.query_row( .query_row(
"SELECT MAX(observed_at) FROM local_sacd_images "SELECT MAX(observed_at) FROM local_sacd_images
@@ -101,7 +134,25 @@ impl LibraryDatabase {
rows.collect::<Result<Vec<_>, _>>() rows.collect::<Result<Vec<_>, _>>()
.map_err(database_error)? .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 { return Ok(SacdImportResult {
track_ids, track_ids,
inserted: 0, inserted: 0,
@@ -234,27 +285,319 @@ impl LibraryDatabase {
transaction transaction
.execute( .execute(
"INSERT INTO local_sacd_images "INSERT INTO local_sacd_images
(fingerprint,image_path,image_size_bytes,image_modified_ns,observed_at) (fingerprint,image_path,image_size_bytes,image_modified_ns,observed_at,parser_revision)
VALUES (?1,?2,?3,?4,?5) VALUES (?1,?2,?3,?4,?5,?6)
ON CONFLICT(fingerprint) DO UPDATE SET ON CONFLICT(fingerprint) DO UPDATE SET
image_path=excluded.image_path, image_path=excluded.image_path,
image_size_bytes=excluded.image_size_bytes, image_size_bytes=excluded.image_size_bytes,
image_modified_ns=excluded.image_modified_ns, image_modified_ns=excluded.image_modified_ns,
observed_at=excluded.observed_at", observed_at=excluded.observed_at,
parser_revision=excluded.parser_revision",
params![ params![
import.fingerprint, import.fingerprint,
import.image_path, import.image_path,
to_i64(import.image_size_bytes), to_i64(import.image_size_bytes),
import.image_modified_ns, import.image_modified_ns,
import.observed_at, import.observed_at,
import.parser_revision,
], ],
) )
.map_err(database_error)?; .map_err(database_error)?;
observe_root(
&transaction,
root_id,
&import.fingerprint,
observed_generation,
)?;
transaction.commit().map_err(database_error)?; transaction.commit().map_err(database_error)?;
Ok(result) 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::<Result<Vec<_>, _>>()
.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::<Result<Vec<_>, _>>()
.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<String>, Option<String>)> = {
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::<Result<Vec<_>, _>>()
.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<String> = {
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::<Result<Vec<_>, _>>()
.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<usize, LibraryError> {
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<usize, LibraryError> {
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<usize, LibraryError> {
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<i64>,
) -> Result<usize, LibraryError> {
let fingerprints: Vec<String> = 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::<Result<Vec<_>, _>>()
.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::<Result<Vec<_>, _>>()
.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<Vec<(u32, &LocalTrack)>, LibraryError> { fn validate_import(import: &SacdImageImport) -> Result<Vec<(u32, &LocalTrack)>, LibraryError> {
@@ -327,6 +670,12 @@ fn remove_generation(
params![fingerprint], params![fingerprint],
) )
.map_err(database_error)?; .map_err(database_error)?;
transaction
.execute(
"DELETE FROM local_sacd_roots WHERE fingerprint=?1",
params![fingerprint],
)
.map_err(database_error)?;
transaction transaction
.execute( .execute(
"DELETE FROM local_sacd_images WHERE fingerprint=?1", "DELETE FROM local_sacd_images WHERE fingerprint=?1",
@@ -450,10 +799,13 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
fn fresh_db() -> (TempDir, LibraryDatabase) { fn fresh_db() -> (TempDir, LibraryDatabase, i64) {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let db = LibraryDatabase::open(&temp.path().join("library.db")).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 { fn image(fingerprint: &str, path: &str, count: u32) -> SacdImageImport {
@@ -463,6 +815,7 @@ mod tests {
image_size_bytes: 4_700_000_000, image_size_bytes: 4_700_000_000,
image_modified_ns: 123, image_modified_ns: 123,
observed_at: 456, observed_at: 456,
parser_revision: 2,
tracks: (1..=count) tracks: (1..=count)
.map(|number| LocalTrack { .map(|number| LocalTrack {
file_path: format!("sacd:{path}#{number}"), file_path: format!("sacd:{path}#{number}"),
@@ -486,11 +839,130 @@ mod tests {
} }
} }
fn seed_unowned_generation(db: &LibraryDatabase, import: &SacdImageImport) -> Vec<i64> {
let ids = import
.tracks
.iter()
.map(|track| db.insert_track(track).unwrap())
.collect::<Vec<_>>();
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] #[test]
fn import_is_persistent_and_reimporting_a_moved_image_keeps_row_ids() { 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 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(); .unwrap();
assert_eq!((first.inserted, first.updated, first.removed), (3, 0, 0)); assert_eq!((first.inserted, first.updated, first.removed), (3, 0, 0));
db.add_local_track_to_playlist(7, first.track_ids[1], 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); let mut moved = image("sacd-a", "/nas/new.iso", 3);
moved.tracks[1].title = "Corrected".to_string(); 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.inserted, second.updated, second.removed), (0, 3, 0));
assert_eq!(second.track_ids, first.track_ids); assert_eq!(second.track_ids, first.track_ids);
assert_eq!( assert_eq!(
@@ -533,20 +1008,58 @@ mod tests {
assert_eq!(source, "user", "the existing local router must own the row"); 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] #[test]
fn only_a_complete_successful_generation_prunes_obsolete_tracks() { fn only_a_complete_successful_generation_prunes_obsolete_tracks() {
let (_temp, db) = fresh_db(); let (_temp, db, root_id) = fresh_db();
let first = 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(); .unwrap();
let mut invalid = image("sacd-a", "/music/disc.iso", 2); let mut invalid = image("sacd-a", "/music/disc.iso", 2);
invalid.tracks[1].file_path = "sacd:/different.iso#2".to_string(); 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()); assert!(db.get_track(first.track_ids[3]).unwrap().is_some());
let shorter = db 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(); .unwrap();
assert_eq!(shorter.removed, 2); assert_eq!(shorter.removed, 2);
assert_eq!(shorter.track_ids, first.track_ids[..2]); assert_eq!(shorter.track_ids, first.track_ids[..2]);
@@ -556,16 +1069,16 @@ mod tests {
#[test] #[test]
fn an_older_completed_snapshot_cannot_overwrite_a_newer_correction() { 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); let mut newer = image("sacd-a", "/music/disc.iso", 2);
newer.observed_at = 200; newer.observed_at = 200;
newer.tracks[0].title = "New naming".to_string(); 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); let mut older = image("sacd-a", "/music/disc.iso", 2);
older.observed_at = 100; older.observed_at = 100;
older.tracks[0].title = "Late old naming".to_string(); 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!(outcome.stale);
assert_eq!( assert_eq!(
@@ -580,12 +1093,12 @@ mod tests {
#[test] #[test]
fn a_successfully_parsed_replacement_at_the_same_path_replaces_the_old_disc() { 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 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(); .unwrap();
let new = db 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(); .unwrap();
assert_eq!(new.removed, 2); assert_eq!(new.removed, 2);
@@ -610,32 +1123,33 @@ mod tests {
#[test] #[test]
fn clear_library_removes_tracks_and_sacd_identity_together() { fn clear_library_removes_tracks_and_sacd_identity_together() {
let (_temp, db) = fresh_db(); let (_temp, db, root_id) = fresh_db();
db.import_sacd_image(&image("sacd-a", "/music/disc.iso", 2)) db.import_sacd_image(root_id, 1, &image("sacd-a", "/music/disc.iso", 2))
.unwrap(); .unwrap();
db.clear_all_tracks().unwrap(); db.clear_all_tracks().unwrap();
let counts: (i64, i64, i64) = db let counts: (i64, i64, i64, i64) = db
.with_connection(|connection| { .with_connection(|connection| {
connection.query_row( connection.query_row(
"SELECT "SELECT
(SELECT COUNT(*) FROM local_tracks), (SELECT COUNT(*) FROM local_tracks),
(SELECT COUNT(*) FROM local_sacd_images), (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(); .unwrap();
assert_eq!(counts, (0, 0, 0)); assert_eq!(counts, (0, 0, 0, 0));
} }
#[test] #[test]
fn the_full_sacd_track_number_domain_imports_without_a_hidden_cap() { 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 started = std::time::Instant::now();
let result = db 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(); .unwrap();
let sqlite_bytes: i64 = db let sqlite_bytes: i64 = db
.with_connection(|connection| { .with_connection(|connection| {
+88 -12
View File
@@ -18,10 +18,14 @@
use std::path::Path; use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; 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}; 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. /// Fallback naming for an image whose Master TOC carries no text.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SacdLabels { pub struct SacdLabels {
@@ -235,6 +239,7 @@ pub fn build_image_rows(path: &Path, labels: &SacdLabels) -> Result<SacdImageRow
image_size_bytes: facts.size_bytes, image_size_bytes: facts.size_bytes,
image_modified_ns: facts.modified_ns, image_modified_ns: facts.modified_ns,
observed_at: observation_token(), observed_at: observation_token(),
parser_revision: SACD_PARSER_REVISION,
tracks: tracks.clone(), tracks: tracks.clone(),
}; };
Ok(SacdImageRows { Ok(SacdImageRows {
@@ -258,6 +263,10 @@ pub struct SacdScanSummary {
pub unchanged: u32, pub unchanged: u32,
/// Files without the `SACDMTOC` signature — silently left alone. /// Files without the `SACDMTOC` signature — silently left alone.
pub ignored: u32, pub ignored: u32,
/// Persistent virtual tracks removed after a complete root walk.
pub removed: u32,
/// True only when the complete walk reached its ownership prune.
pub prune_authorized: bool,
/// Real SACDs the parser or the import rejected: (path, reason). /// Real SACDs the parser or the import rejected: (path, reason).
pub failed: Vec<(String, String)>, pub failed: Vec<(String, String)>,
} }
@@ -269,16 +278,19 @@ fn is_iso(path: &Path) -> bool {
} }
/// Find and import the SACD images under one root. Runs after the root's /// 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 /// audio/cue passes; nothing here touches `local_scan_files`. A complete walk
/// prune, and a disc that disappears keeps its rows (the same rule the manual /// prunes images no longer present under this root, while cancellation or a
/// import follows for a NAS that is down). /// traversal error preserves the last known-good rows.
pub fn scan_root_for_sacd( pub fn scan_root_for_sacd(
db: &LibraryDatabase, db: &LibraryDatabase,
root_id: i64,
observed_generation: i64,
root: &Path, root: &Path,
labels: &SacdLabels, labels: &SacdLabels,
cancel: &AtomicBool, cancel: &AtomicBool,
) -> SacdScanSummary { ) -> SacdScanSummary {
let mut summary = SacdScanSummary::default(); let mut summary = SacdScanSummary::default();
let mut traversal_complete = true;
let walk = walkdir::WalkDir::new(root) let walk = walkdir::WalkDir::new(root)
.follow_links(true) .follow_links(true)
.sort_by_file_name() .sort_by_file_name()
@@ -291,6 +303,7 @@ pub fn scan_root_for_sacd(
Ok(entry) => entry, Ok(entry) => entry,
Err(error) => { Err(error) => {
log::debug!("[sacd] scan walk error: {error}"); log::debug!("[sacd] scan walk error: {error}");
traversal_complete = false;
continue; continue;
} }
}; };
@@ -303,26 +316,53 @@ pub fn scan_root_for_sacd(
// Known and unchanged: no file I/O at all. // Known and unchanged: no file I/O at all.
let facts = image_facts(path); 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) => { Ok(true) => {
summary.unchanged = summary.unchanged.saturating_add(1); summary.unchanged = summary.unchanged.saturating_add(1);
continue; continue;
} }
Ok(false) => {} Ok(false) => {}
Err(error) => { Err(error) => {
traversal_complete = false;
summary.failed.push((path_string, error.to_string())); summary.failed.push((path_string, error.to_string()));
continue; continue;
} }
} }
// Signature first: a DVD or a data ISO leaves here silently. // Signature first: a DVD or a data ISO leaves here silently.
if !qbz_disc::sacd::is_sacd_image(path) { match qbz_disc::sacd::sniff_sacd_image(path) {
summary.ignored = summary.ignored.saturating_add(1); Ok(SacdSniff::Sacd) => {}
continue; 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) { 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 => { Ok(result) if result.stale => {
summary.unchanged = summary.unchanged.saturating_add(1); 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 summary
} }
@@ -385,12 +438,21 @@ mod tests {
let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap();
let root = temp.path().join("music"); let root = temp.path().join("music");
std::fs::create_dir_all(root.join("Some DVD")).unwrap(); 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")); 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("not-an-image.iso"), b"just bytes").unwrap();
std::fs::write(root.join("song.flac"), b"").unwrap(); std::fs::write(root.join("song.flac"), b"").unwrap();
let summary = let summary = scan_root_for_sacd(
scan_root_for_sacd(&db, &root, &SacdLabels::default(), &AtomicBool::new(false)); &db,
root_id,
1,
&root,
&SacdLabels::default(),
&AtomicBool::new(false),
);
assert_eq!( assert_eq!(
summary, summary,
SacdScanSummary { SacdScanSummary {
@@ -398,11 +460,20 @@ mod tests {
imported: 0, imported: 0,
unchanged: 0, unchanged: 0,
ignored: 2, ignored: 2,
removed: 0,
prune_authorized: true,
failed: Vec::new(), failed: Vec::new(),
} }
); );
assert!(!db 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()); .unwrap());
} }
@@ -411,8 +482,13 @@ mod tests {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap(); let db = LibraryDatabase::open(&temp.path().join("library.db")).unwrap();
write_plain_iso(&temp.path().join("movie.iso")); 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( let summary = scan_root_for_sacd(
&db, &db,
root_id,
1,
temp.path(), temp.path(),
&SacdLabels::default(), &SacdLabels::default(),
&AtomicBool::new(true), &AtomicBool::new(true),
+6 -2
View File
@@ -1515,18 +1515,22 @@ fn scan_root(
// or import rejection is a scan error like any unreadable file. // or import rejection is a scan error like any unreadable file.
let sacd = crate::sacd_scan::scan_root_for_sacd( let sacd = crate::sacd_scan::scan_root_for_sacd(
db, db,
folder.id,
state.generation.min(i64::MAX as u64) as i64,
Path::new(&folder.path), Path::new(&folder.path),
&crate::sacd_scan::SacdLabels::default(), &crate::sacd_scan::SacdLabels::default(),
cancel, cancel,
); );
if sacd.candidates > 0 { if sacd.candidates > 0 || sacd.removed > 0 || !sacd.failed.is_empty() {
log::info!( 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, folder.id,
sacd.candidates, sacd.candidates,
sacd.imported, sacd.imported,
sacd.unchanged, sacd.unchanged,
sacd.ignored, sacd.ignored,
sacd.removed,
sacd.prune_authorized,
sacd.failed.len(), sacd.failed.len(),
); );
} }
+38 -34
View File
@@ -772,7 +772,7 @@ Rectangle {
function applyFilter(rows, selectedFilter) { function applyFilter(rows, selectedFilter) {
var selected = selectedFilter || ({}) var selected = selectedFilter || ({})
if (Object.keys(selected).length === 0) return rows 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 var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|| selected.mp3 || selected.aac || selected.other || selected.mp3 || selected.aac || selected.other
var sAny = selected.local || selected.offline || selected.plex var sAny = selected.local || selected.offline || selected.plex
@@ -782,41 +782,45 @@ Rectangle {
for (var i = 0; i < rows.length; i++) { for (var i = 0; i < rows.length; i++) {
var r = rows[i] var r = rows[i]
if (selected.favorite === true && !root.albumFavorite(r)) continue if (selected.favorite === true && !root.albumFavorite(r)) continue
var tier = (r.qualityTier || "").toLowerCase() if (qAny || fAny || sAny) {
var fmt = (r.format || "").toLowerCase() // Test all active media sections against ONE physical copy.
if (qAny) { // Independent aggregate arrays are not enough: a logical
var qok = (selected.dsd && tier === "dsd") // album can carry DSD on its purchased copy and FLAC on a
|| (selected.hires && (tier === "hires" || tier === "max")) // different local copy. DSD+FLAC must not manufacture a
|| (selected.cd && tier === "cd") // parent whose filtered detail has zero tracks.
|| (selected.lossy && (tier === "mp3" || tier === "lossy")) var variants = r.mediaVariants || []
if (!qok) continue if (variants.length === 0) {
} var sources = r.sources && r.sources.length > 0
if (fAny) { ? r.sources : [r.source || "local"]
var fok = selected[fmt] === true variants = []
|| (selected.other === true && known[fmt] !== 1) for (var si = 0; si < sources.length; si++) {
if (!fok) continue variants.push({ "qualityTier": r.qualityTier || "",
} "format": r.format || "",
if (sAny) { "source": sources[si] })
// 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 var mediaMatches = false
// of chips is concerned, and the badge reads `sourceRaw` for (var vi = 0; vi < variants.length; vi++) {
// instead of splitting the bucket. var variant = variants[vi]
// var tier = (variant.qualityTier || "").toLowerCase()
// The normalisation below is the reference's §10-H bug kept var fmt = (variant.format || "").toLowerCase()
// closed: Tauri's three arms never mention `qobuz_purchase`, var qok = !qAny
// so a raw word reaching here matches no chip and ticking ANY || (selected.dsd && tier === "dsd")
// source filter hides every purchased album, silently. || (selected.hires && (tier === "hires" || tier === "max"))
var sources = r.sources && r.sources.length > 0 || (selected.cd && tier === "cd")
? r.sources : [r.source || "local"] || (selected.lossy && (tier === "mp3" || tier === "lossy"))
var sourceMatches = false var fok = !fAny || selected[fmt] === true
for (var si = 0; si < sources.length; si++) { || (selected.other === true && known[fmt] !== 1)
if (selected[sourceBucket(sources[si])] === true) { // `sourceBucket` folds qobuz_purchase into Offline while
sourceMatches = true // its raw word remains available to the gold badge.
var sok = !sAny
|| selected[sourceBucket(variant.source)] === true
if (qok && fok && sok) {
mediaMatches = true
break break
} }
} }
if (!sourceMatches) continue if (!mediaMatches) continue
} }
out.push(r) out.push(r)
} }
@@ -886,7 +890,7 @@ Rectangle {
function artistMatchesFilter(artist, selected) { function artistMatchesFilter(artist, selected) {
if (!selected || Object.keys(selected).length === 0) return true 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 var fAny = selected.flac || selected.alac || selected.ape || selected.wav
|| selected.mp3 || selected.aac || selected.other || selected.mp3 || selected.aac || selected.other
var sAny = selected.local || selected.offline || selected.plex var sAny = selected.local || selected.offline || selected.plex
@@ -52,11 +52,8 @@ Item {
} }
} }
function scheduleMutableRestore() { function scheduleMutableRestore() {
var expected = cell.identity mutableRestore.expectedIdentity = cell.identity
Qt.callLater(function () { mutableRestore.restart()
if (cell.identity === expected)
cell.restoreMutableBindings()
})
} }
onItemChanged: { onItemChanged: {
// A popup is a window-overlay child; close it while the old loaded // A popup is a window-overlay child; close it while the old loaded
@@ -71,6 +68,19 @@ Item {
width: 200 width: 200
height: 246 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 { Component {
id: albumCardComp id: albumCardComp
AlbumCard { AlbumCard {
@@ -455,16 +455,29 @@ Item {
}) })
} }
function scheduleMutableRestore() { function scheduleMutableRestore() {
var expected = cardCell.slot mutableRestore.expected = cardCell.slot
Qt.callLater(function () { mutableRestore.restart()
if (cardCell.slot === expected)
cardCell.restoreMutableBindings()
})
} }
onSlotChanged: cardCell.scheduleMutableRestore() onSlotChanged: cardCell.scheduleMutableRestore()
Component.onCompleted: Component.onCompleted:
cardCell.scheduleMutableRestore() 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 { AlbumCard {
id: albumCard id: albumCard
localMode: true localMode: true
@@ -118,6 +118,7 @@ Item {
var tr = QbzSession.trRev var tr = QbzSession.trRev
var filters = [ var filters = [
{ key: "favorite", label: QbzSession.tr("Favorites only", tr) }, { key: "favorite", label: QbzSession.tr("Favorites only", tr) },
{ key: "dsd", label: "DSD" },
{ key: "hires", label: QbzSession.tr("Hi-Res", tr) }, { key: "hires", label: QbzSession.tr("Hi-Res", tr) },
{ key: "cd", label: QbzSession.tr("CD", tr) }, { key: "cd", label: QbzSession.tr("CD", tr) },
{ key: "lossy", label: QbzSession.tr("Lossy", tr) }, { key: "lossy", label: QbzSession.tr("Lossy", tr) },
+1 -16
View File
@@ -174,22 +174,7 @@ fn map_album(requested_id: String, album: Album) -> QuickViewDoc {
} }
fn local_quality_rank(track: &LocalTrack) -> (u8, u32, u64) { 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) { crate::local_rows::media_quality_rank(&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
},
)
} }
/// Build a preview from the best physical copy without calling /// Build a preview from the best physical copy without calling
-6
View File
@@ -397,9 +397,6 @@ pub fn apply() {
edited: true, edited: true,
}; };
qbz_disc::store::put_user(&identity.fingerprint, identity.disc_id.as_deref(), &memory); 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!( log::info!(
"[qbz-qt] disc meta: applied {:?} by {:?} from {}", "[qbz-qt] disc meta: applied {:?} by {:?} from {}",
meta.title, meta.title,
@@ -419,9 +416,6 @@ pub fn apply() {
if let Some(art) = crate::cdda_qt::fetch_cover_for(&release, None).await { if let Some(art) = crate::cdda_qt::fetch_cover_for(&release, None).await {
qbz_disc::store::set_cover(&fingerprint, &art); qbz_disc::store::set_cover(&fingerprint, &art);
crate::local_ephemeral::set_session_artwork(&art); crate::local_ephemeral::set_session_artwork(&art);
if identity.kind == crate::disc_identity::DiscKind::Sacd {
crate::sacd_qt::persist_current_session(&fingerprint);
}
} }
}); });
} }
+36 -29
View File
@@ -30,15 +30,17 @@ use cxx_qt_lib::QString;
use qbz_app::settings::local_favorites::LocalFavItem; use qbz_app::settings::local_favorites::LocalFavItem;
use qbz_app::shell::AppRuntime; use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter; use qbz_core::LoggingAdapter;
use qbz_library::{AudioFormat, LocalTrack}; #[cfg(test)]
use qbz_library::AudioFormat;
use qbz_library::LocalTrack;
use qbz_models::QueueTrack; use qbz_models::QueueTrack;
use serde::Serialize; use serde::Serialize;
use crate::local_bridge::ui; use crate::local_bridge::ui;
use crate::local_playback::{fill_missing_covers, local_queue_track}; use crate::local_playback::{fill_missing_covers, local_queue_track};
use crate::local_rows::{ use crate::local_rows::{
album_favorite_source, album_key, badge_source, badge_source_raw, map_track, tier_of, to_json, album_favorite_source, album_key, badge_source, badge_source_raw, map_track,
total_duration, AlbumRow, TrackRow, media_quality_rank, tier_of, to_json, total_duration, AlbumMediaVariant, AlbumRow, TrackRow,
}; };
use crate::local_state::{state, with_art}; use crate::local_state::{state, with_art};
@@ -147,30 +149,9 @@ pub struct GenreAlbumDetailDoc {
// Version splitting (local_library.rs `open_local_album` 1:1) // 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) { fn version_rank(t: &LocalTrack) -> (u8, u32, u64) {
let lossless = matches!( media_quality_rank(&t.format, t.bit_depth, t.sample_rate)
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)
} }
/// Group the album's tracks by SOURCE DIRECTORY, sort each copy by /// 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.disc_number.hash(&mut identity);
track.track_number.hash(&mut identity); track.track_number.hash(&mut identity);
} }
let detail = let detail = crate::local_rows::detail_of(&t.format, t.bit_depth, t.sample_rate);
crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate));
let fmt = t.format.to_string(); let fmt = t.format.to_string();
let raw_source = badge_source_raw(t.source.as_deref()); let raw_source = badge_source_raw(t.source.as_deref());
AlbumVersion { AlbumVersion {
@@ -492,8 +472,14 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
} }
let best = tracks let best = tracks
.iter() .iter()
.max_by_key(|t| t.bit_depth.unwrap_or(0)) .max_by_key(|track| version_rank(track))
.unwrap_or(first); .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| { let card = state(|s| {
s.albums s.albums
.iter() .iter()
@@ -542,6 +528,11 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
best.sample_rate, best.sample_rate,
), ),
format: best.format.to_string(), 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: { genres: {
let mut values = tracks let mut values = tracks
.iter() .iter()
@@ -1056,6 +1047,22 @@ mod version_tests {
assert!(info.quality.contains("FLAC")); 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] #[test]
fn genres_detail_keeps_each_named_disc_and_its_own_cover() { fn genres_detail_keeps_each_named_disc_and_its_own_cover() {
let nonce = std::time::SystemTime::now() let nonce = std::time::SystemTime::now()
+100 -25
View File
@@ -16,7 +16,9 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use qbz_library::album_grouping::AlbumGroupMode; 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_album_actions::AlbumDetailDoc;
use crate::local_artist_match::{ use crate::local_artist_match::{
@@ -24,8 +26,8 @@ use crate::local_artist_match::{
merge_artists, normalize_artist, AlbumCredit, ArtistInput, merge_artists, normalize_artist, AlbumCredit, ArtistInput,
}; };
use crate::local_rows::{ use crate::local_rows::{
artist_key, badge_source, map_album, map_album_with_artists, map_track, tier_of, AlbumRow, album_media_variants, artist_key, badge_source, map_album, map_album_with_artists, map_track,
ArtistRow, LocalCounts, TrackRow, media_quality_rank, tier_of, AlbumMediaVariant, AlbumRow, ArtistRow, LocalCounts, TrackRow,
}; };
use crate::local_state::{ use crate::local_state::{
commit_tracks_page, group_mode, state, tracks_generation, with_art, with_db, commit_tracks_page, group_mode, state, tracks_generation, with_art, with_db,
@@ -71,6 +73,14 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
let t_sql = t0.elapsed(); let t_sql = t0.elapsed();
let identity_started = std::time::Instant::now(); let identity_started = std::time::Instant::now();
let source_album_count = page.albums.len(); 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::<HashMap<_, _>>();
let (albums, version_ids) = coalesce_album_versions(page.albums); let (albums, version_ids) = coalesce_album_versions(page.albums);
let t_identity = identity_started.elapsed(); let t_identity = identity_started.elapsed();
let total = albums.len() as u64; let total = albums.len() as u64;
@@ -88,8 +98,15 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
albums albums
.into_iter() .into_iter()
.map(|a| { .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); 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::<Vec<AlbumRow>>() .collect::<Vec<AlbumRow>>()
}); });
@@ -333,30 +350,42 @@ fn coalesce_album_versions(
} }
fn album_quality_rank(album: &LocalAlbum) -> (u8, u32, u64, u32) { fn album_quality_rank(album: &LocalAlbum) -> (u8, u32, u64, u32) {
let lossless = matches!( let (tier, depth, sample_rate) =
album.format, media_quality_rank(&album.format, album.bit_depth, album.sample_rate);
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
};
(tier, depth, sample_rate, album.track_count) (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<String, Vec<AlbumMediaVariant>>,
) -> Vec<AlbumMediaVariant> {
let mut variants = version_ids
.into_iter()
.flatten()
.filter_map(|id| physical_media.get(id))
.flatten()
.cloned()
.collect::<Vec<_>>();
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 { fn source_order(source: &str) -> u8 {
match source { match source {
"user" | "local" => 0, "user" | "local" => 0,
@@ -1604,6 +1633,52 @@ mod phase_a_tests {
assert_eq!(rows.len(), 2, "title and artist alone must never merge"); 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::<HashMap<_, _>>();
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] #[test]
fn coverless_best_audio_copy_uses_the_best_available_group_cover() { fn coverless_best_audio_copy_uses_the_best_available_group_cover() {
let tracks = [("Eres Toda una Mujer", 209), ("Amar y Querer", 191)]; let tracks = [("Eres Toda una Mujer", 209), ("Amar y Querer", 191)];
+19 -1
View File
@@ -17,7 +17,7 @@ use serde::Serialize;
use crate::local_bridge::ui; use crate::local_bridge::ui;
use crate::local_rows::{ use crate::local_rows::{
album_favorite_source, badge_source, badge_source_raw, catalog_album_sources, detail_of, 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; 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 = badge_source(Some(record.source_raw_or_kind()));
let source_raw = badge_source_raw(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 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(); let favoriteable = album_favorite_source(&sources).is_some();
NativeAlbum { NativeAlbum {
row: AlbumRow { row: AlbumRow {
@@ -1094,6 +1111,7 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
record.sample_rate_hz.unwrap_or(0) as f64, record.sample_rate_hz.unwrap_or(0) as f64,
), ),
format: record.format.to_ascii_uppercase(), format: record.format.to_ascii_uppercase(),
media_variants,
genres: Vec::new(), genres: Vec::new(),
art_key, art_key,
source, source,
+20 -2
View File
@@ -16,8 +16,8 @@ use serde::Serialize;
use crate::local_bridge::ui; use crate::local_bridge::ui;
use crate::local_rows::{ use crate::local_rows::{
badge_source, badge_source_raw, catalog_album_sources, detail_of, total_duration, AlbumRow, badge_source, badge_source_raw, catalog_album_sources, detail_of, total_duration,
ArtistRow, AlbumMediaVariant, AlbumRow, ArtistRow,
}; };
const PAGE_ENTRIES: usize = 100; 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 = badge_source(Some(source_word(record)));
let source_raw = badge_source_raw(Some(source_word(record))); let source_raw = badge_source_raw(Some(source_word(record)));
let sources = catalog_album_sources(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(); let favoriteable = crate::local_rows::album_favorite_source(&sources).is_some();
NativeAlbum { NativeAlbum {
row: AlbumRow { row: AlbumRow {
@@ -1403,6 +1420,7 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
record.sample_rate_hz.unwrap_or(0) as f64, record.sample_rate_hz.unwrap_or(0) as f64,
), ),
format: record.format.to_ascii_uppercase(), format: record.format.to_ascii_uppercase(),
media_variants,
genres: Vec::new(), genres: Vec::new(),
art_key, art_key,
source, source,
+7 -7
View File
@@ -1,9 +1,9 @@
//! Ephemeral folders and live disc sessions. //! Ephemeral folders and live disc sessions.
//! //!
//! A folder opened outside the indexed library and a physical CD never land a //! A folder opened outside the indexed library, a physical CD and a manually
//! row in `library.db`. A parsed SACD is the deliberate exception: `sacd_qt` //! opened SACD image never land a row in `library.db`. SACD images become
//! adopts its virtual tracks into the persistent catalogue first, then uses //! persistent only when the regular folder scanner finds them below a
//! this same in-memory pane for the immediate open experience. //! registered Local Library root.
//! //!
//! Qt/QML port of the shipping Slint pair (`crates/qbz/src/ephemeral.rs` + //! 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: //! 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 /// 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, /// 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 /// 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 /// 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 /// find a different CD (or none), so it deliberately starts clean. SACD
/// catalogue persistence is separate and keyed by its TOC fingerprint; the /// library indexing is a separate folder-scan concern; the user still
/// user still explicitly opens an image to create this live pane. /// explicitly opens an image to create this live pane.
pub fn adopt_tracks(label: &str, tracks: Vec<LocalTrack>) { pub fn adopt_tracks(label: &str, tracks: Vec<LocalTrack>) {
let label = label.to_string(); let label = label.to_string();
// Taken HERE, on the reader's own thread, so it overlaps the reader's // Taken HERE, on the reader's own thread, so it overlaps the reader's
+95
View File
@@ -31,6 +31,18 @@ use serde::Serialize;
// Rows (the QML contract) // 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 /// 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 /// of the ACTIVE identity mode — folder or metadata — so the detail query can
/// round-trip it. Plex albums carry the content-hash key `plex:<hash>`. /// round-trip it. Plex albums carry the content-hash key `plex:<hash>`.
@@ -52,6 +64,11 @@ pub struct AlbumRow {
#[serde(rename = "qualityDetail")] #[serde(rename = "qualityDetail")]
pub quality_detail: String, pub quality_detail: String,
pub format: 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<AlbumMediaVariant>,
/// Every genre represented by this logical album, across all versions. /// Every genre represented by this logical album, across all versions.
pub genres: Vec<String>, pub genres: Vec<String>,
#[serde(rename = "artKey")] #[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<u32>,
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 /// 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 /// (MP3 first, then max / hires / cd) so a local card and a Qobuz card can
/// never disagree. /// never disagree.
@@ -269,6 +325,43 @@ pub fn detail_of(format: &AudioFormat, bit_depth: Option<u32>, sample_rate_hz: f
crate::home_qt::quality_detail_from_parts(bit_depth, Some(sample_rate_hz)) 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<AlbumMediaVariant> {
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::<Vec<_>>();
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 { pub fn basename(path: &str) -> String {
std::path::Path::new(path) std::path::Path::new(path)
.file_name() .file_name()
@@ -493,6 +586,7 @@ pub fn map_album_with_artists(
art: &mut HashMap<String, (SourceId, String)>, art: &mut HashMap<String, (SourceId, String)>,
artists: Vec<String>, artists: Vec<String>,
) -> AlbumRow { ) -> AlbumRow {
let media_variants = album_media_variants(&a);
let key = album_key(&a.id); let key = album_key(&a.id);
if let Some(p) = a.artwork_path.as_ref().filter(|p| !p.is_empty()) { 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()); 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_tier: tier_of(&a.format, a.bit_depth, a.sample_rate).into(),
quality_detail: detail_of(&a.format, a.bit_depth, a.sample_rate), quality_detail: detail_of(&a.format, a.bit_depth, a.sample_rate),
format: a.format.to_string(), format: a.format.to_string(),
media_variants,
genres: a.genres, genres: a.genres,
year: a.year.map(|y| y.to_string()).unwrap_or_default(), year: a.year.map(|y| y.to_string()).unwrap_or_default(),
duration: total_duration(a.total_duration_secs), duration: total_duration(a.total_duration_secs),
+39
View File
@@ -747,6 +747,20 @@ mod phase_a_tests {
assert!(!view_qml.contains("readonly property var tracksVisible: {")); 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] #[test]
fn genres_details_virtualizes_the_nested_box_set_rows() { fn genres_details_virtualizes_the_nested_box_set_rows() {
// The outer ListView is album-granular: a 150-track box is one visible // 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")); 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] #[test]
fn explicit_ephemeral_open_routes_globally_and_keeps_a_loading_surface() { fn explicit_ephemeral_open_routes_globally_and_keeps_a_loading_surface() {
let router = include_str!("../qml/shell/ContentRouter.qml"); let router = include_str!("../qml/shell/ContentRouter.qml");
+11 -78
View File
@@ -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 //! 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 //! 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. //! 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 //! Scope: the stereo DSD64 area of a raw Scarlet Book or hybrid image, either
//! uncompressed image. A multichannel-only disc, a DST-compressed area or an //! flat or DST-compressed. A multichannel-only, malformed or unsupported image
//! image with no ISO 9660 layer is REPORTED, never approximated into silence. //! is reported, never approximated into silence.
//! //!
//! The rows themselves come from `qbz_library::sacd_scan::build_image_rows`, //! 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 //! the same parser/row builder the folder scan uses. Catalogue ownership does
//! only adds what a manual open needs — the session identity, the ephemeral //! not: only the folder scanner may import those rows into `library.db`.
//! session and the translated fallback labels.
use qbz_library::sacd_scan::{image_facts, observation_token};
use crate::local_ephemeral; use crate::local_ephemeral;
@@ -30,11 +27,12 @@ pub fn open_image(path: &std::path::Path) -> Result<usize, String> {
qbz_disc::sacd::SacdError::NoStereoArea => { qbz_disc::sacd::SacdError::NoStereoArea => {
qbz_i18n::t("That image has no stereo audio area.") qbz_i18n::t("That image has no stereo audio area.")
} }
qbz_disc::sacd::SacdError::Dst => { qbz_disc::sacd::SacdError::MissingMasterToc
qbz_i18n::t("This disc is DST-compressed, which QBZ cannot play yet.") | 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."), qbz_disc::sacd::SacdError::Iso(_) => qbz_i18n::t("Could not read the disc."),
other => format!("{other}"), _ => qbz_i18n::t("That SACD image is damaged or unsupported."),
} }
})?; })?;
@@ -55,75 +53,10 @@ pub fn open_image(path: &std::path::Path) -> Result<usize, String> {
rows.album 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); local_ephemeral::adopt_tracks(&rows.album, rows.tracks);
Ok(count) 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)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
+1
View File
@@ -95,6 +95,7 @@
$out/share/icons/hicolor/"$size"x"$size"/apps/qbz.png $out/share/icons/hicolor/"$size"x"$size"/apps/qbz.png
done done
install -Dm644 $src/LICENSE $out/share/licenses/qbz/LICENSE install -Dm644 $src/LICENSE $out/share/licenses/qbz/LICENSE
cp -r $src/licenses $out/share/licenses/qbz/third-party
''; '';
meta = with pkgs.lib; { meta = with pkgs.lib; {
+201
View File
@@ -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.
+22 -8
View File
@@ -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 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 carries the notices that must travel **with the binaries**. Every release
artifacts bundle their dependencies rather than resolving them from a system format installs or embeds this directory; some entries apply only to artifacts
package manager the way the Linux packages do. that bundle the corresponding runtime.
## Qt 6 — LGPL v3 ## Qt 6 — LGPL v3
The Windows MSI and the portable zip ship the Qt runtime. QBZ links against it The Windows, macOS, AppImage, Flatpak and Snap artifacts ship the Qt runtime.
**dynamically** and does not modify it, which is what the LGPL requires for the QBZ links against it **dynamically** and does not modify it, which is what the
arrangement below; `LGPL-3.0.txt` is the full text, verbatim from LGPL requires for the arrangement below; `LGPL-3.0.txt` is the full text,
<https://www.gnu.org/licenses/lgpl-3.0.txt>. verbatim from <https://www.gnu.org/licenses/lgpl-3.0.txt>.
**Version:** Qt 6.9.3, `win64_msvc2022_64`, installed by **Version:** Qt 6.9.3, `win64_msvc2022_64`, installed by
[aqtinstall](https://github.com/miurahr/aqtinstall) in [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 than source availability. `cargo tree` and `Cargo.lock` are the authoritative
list; the SBOM step in the release workflow is where a generated manifest list; the SBOM step in the release workflow is where a generated manifest
belongs when that lands. 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`.
+11
View File
@@ -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
<https://github.com/bleggett/dst-decoder/blob/main/LICENSE>.
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.
+55
View File
@@ -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
<aad.rijnberg@philips.com>
* Fons Bruekers
Philips Research Laboratories Eindhoven
<fons.bruekers@philips.com>
* Eric Knapen
Philips Digital Systems Laboratories Eindhoven
<h.w.m.knapen@philips.com>
And edited by:
* Richard Theelen
Philips Digital Systems Laboratories Eindhoven
<r.h.m.theelen@philips.com>
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: <none>
Authors:
RT: Richard Theelen, PDSL-labs Eindhoven <r.h.m.theelen@philips.com>
Changes:
08-Mar-2004 RT Initial version
```
+1
View File
@@ -52,4 +52,5 @@ package() {
install -Dm644 packaging/arch/com.blitzfc.qbz.desktop "${pkgdir}/usr/share/applications/com.blitzfc.qbz.desktop" 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 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 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party"
} }
+1
View File
@@ -41,4 +41,5 @@ package() {
install -Dm644 com.blitzfc.qbz.metainfo.xml \ install -Dm644 com.blitzfc.qbz.metainfo.xml \
"${pkgdir}/usr/share/metainfo/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 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party"
} }
+1
View File
@@ -62,4 +62,5 @@ package() {
install -Dm644 packaging/flatpak/com.blitzfc.qbz.metainfo.xml \ install -Dm644 packaging/flatpak/com.blitzfc.qbz.metainfo.xml \
"${pkgdir}/usr/share/metainfo/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 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party"
} }
+1
View File
@@ -37,4 +37,5 @@ package() {
install -Dm644 completions/qbzd.fish "${pkgdir}/usr/share/fish/vendor_completions.d/qbzd.fish" 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 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party"
} }
+1
View File
@@ -62,4 +62,5 @@ package() {
install -Dm644 packaging/linux/qbzd-standalone-README.md \ install -Dm644 packaging/linux/qbzd-standalone-README.md \
"${pkgdir}/usr/share/doc/${pkgname}/README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
install -Dm644 licenses/* -t "${pkgdir}/usr/share/licenses/${pkgname}/third-party"
} }
+1
View File
@@ -71,6 +71,7 @@ modules:
- install -Dm644 payload/qbz.desktop /app/share/applications/com.blitzfc.qbz.desktop - 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/com.blitzfc.qbz.metainfo.xml /app/share/metainfo/com.blitzfc.qbz.metainfo.xml
- install -Dm644 payload/LICENSE /app/share/licenses/com.blitzfc.qbz/LICENSE - 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" - 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 "/app/share/icons/hicolor/${size}x${size}/apps/com.blitzfc.qbz.png"; done
sources: sources:
+2
View File
@@ -72,6 +72,8 @@ src_install() {
insinto /usr/share/metainfo insinto /usr/share/metainfo
doins com.blitzfc.qbz.metainfo.xml doins com.blitzfc.qbz.metainfo.xml
dodoc LICENSE dodoc LICENSE
insinto /usr/share/licenses/${PN}/third-party
doins licenses/*
} }
pkg_postinst() { pkg_postinst() {
+2
View File
@@ -68,6 +68,8 @@ src_install() {
insinto /usr/share/metainfo insinto /usr/share/metainfo
doins packaging/flatpak/com.blitzfc.qbz.metainfo.xml doins packaging/flatpak/com.blitzfc.qbz.metainfo.xml
dodoc LICENSE dodoc LICENSE
insinto /usr/share/licenses/${PN}/third-party
doins licenses/*
} }
pkg_postinst() { pkg_postinst() {
@@ -50,6 +50,8 @@ src_install() {
insinto /usr/share/fish/vendor_completions.d insinto /usr/share/fish/vendor_completions.d
newins "${dir}/completions/qbzd.fish" qbzd.fish newins "${dir}/completions/qbzd.fish" qbzd.fish
dodoc "${dir}/README.md" "${dir}/LICENSE" dodoc "${dir}/README.md" "${dir}/LICENSE"
insinto /usr/share/licenses/${PN}/third-party
doins "${dir}"/licenses/*
} }
pkg_postinst() { pkg_postinst() {
+2
View File
@@ -63,6 +63,8 @@ src_install() {
insinto /usr/share/fish/vendor_completions.d insinto /usr/share/fish/vendor_completions.d
newins "${T}/qbzd.fish" qbzd.fish newins "${T}/qbzd.fish" qbzd.fish
dodoc packaging/linux/qbzd-standalone-README.md LICENSE dodoc packaging/linux/qbzd-standalone-README.md LICENSE
insinto /usr/share/licenses/${PN}/third-party
doins licenses/*
} }
pkg_postinst() { pkg_postinst() {
+3
View File
@@ -50,6 +50,9 @@ contents:
dst: /usr/share/doc/qbzd/README.md dst: /usr/share/doc/qbzd/README.md
- src: LICENSE - src: LICENSE
dst: /usr/share/licenses/qbzd/LICENSE dst: /usr/share/licenses/qbzd/LICENSE
- src: licenses
dst: /usr/share/licenses/qbzd/third-party
type: tree
overrides: overrides:
deb: deb:
+5
View File
@@ -57,6 +57,11 @@ contents:
- src: ./AppDir/usr/qml - src: ./AppDir/usr/qml
dst: /opt/qbz/qml dst: /opt/qbz/qml
type: tree 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 - src: /opt/qbz/bin/qbz
dst: /usr/bin/qbz dst: /usr/bin/qbz
type: symlink type: symlink
+2
View File
@@ -94,6 +94,8 @@ parts:
qml: opt/qbz/qml qml: opt/qbz/qml
qbz.desktop: usr/share/applications/qbz.desktop qbz.desktop: usr/share/applications/qbz.desktop
qbz.png: usr/share/icons/hicolor/256x256/apps/qbz.png 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 # 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 # the non-system xcb/Wayland helpers selected by linuxdeploy; the GNOME
# extension supplies the host graphics, display and font stack. Keep this # extension supplies the host graphics, display and font stack. Keep this
+2
View File
@@ -94,6 +94,8 @@ parts:
qml: opt/qbz/qml qml: opt/qbz/qml
qbz.desktop: usr/share/applications/qbz.desktop qbz.desktop: usr/share/applications/qbz.desktop
qbz.png: usr/share/icons/hicolor/256x256/apps/qbz.png 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 # 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 # the non-system xcb/Wayland helpers selected by linuxdeploy; the GNOME
# extension supplies the host graphics, display and font stack. Keep this # extension supplies the host graphics, display and font stack. Keep this
+4
View File
@@ -58,6 +58,7 @@ install -m644 packaging/linux/qbz.desktop "$qbz_dir/qbz.desktop"
install -m644 packaging/flatpak/com.blitzfc.qbz.metainfo.xml \ install -m644 packaging/flatpak/com.blitzfc.qbz.metainfo.xml \
"$qbz_dir/com.blitzfc.qbz.metainfo.xml" "$qbz_dir/com.blitzfc.qbz.metainfo.xml"
install -m644 LICENSE "$qbz_dir/LICENSE" install -m644 LICENSE "$qbz_dir/LICENSE"
cp -r licenses "$qbz_dir/licenses"
for size in 32 48 64 128 256 512; do for size in 32 48 64 128 256 512; do
install -m644 "packaging/icons/${size}x${size}.png" \ install -m644 "packaging/icons/${size}x${size}.png" \
"$qbz_dir/icons/hicolor/${size}x${size}/apps/qbz.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 crates/qbzd/service/qbzd.service "$qbzd_dir/qbzd.service"
install -m644 packaging/linux/qbzd-standalone-README.md "$qbzd_dir/README.md" install -m644 packaging/linux/qbzd-standalone-README.md "$qbzd_dir/README.md"
install -m644 LICENSE "$qbzd_dir/LICENSE" 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 bash > "$qbzd_dir/completions/qbzd.bash"
"$qbzd_test_bin" completions zsh > "$qbzd_dir/completions/qbzd.zsh" "$qbzd_test_bin" completions zsh > "$qbzd_dir/completions/qbzd.zsh"
"$qbzd_test_bin" completions fish > "$qbzd_dir/completions/qbzd.fish" "$qbzd_test_bin" completions fish > "$qbzd_dir/completions/qbzd.fish"
@@ -111,6 +113,7 @@ for package in "${selected_packages[@]}"; do
case "$PACKAGE" in case "$PACKAGE" in
qbz|qbz-bin) qbz|qbz-bin)
grep -aFq "QBZ/${VERSION}" /usr/bin/qbz grep -aFq "QBZ/${VERSION}" /usr/bin/qbz
test -f /usr/share/licenses/${PACKAGE}/third-party/Apache-2.0-dst-decoder.txt
set +e set +e
QT_QPA_PLATFORM=offscreen timeout 20 qbz >/tmp/qbz-smoke.log 2>&1 QT_QPA_PLATFORM=offscreen timeout 20 qbz >/tmp/qbz-smoke.log 2>&1
status=$? status=$?
@@ -123,6 +126,7 @@ for package in "${selected_packages[@]}"; do
;; ;;
qbzd|qbzd-bin) qbzd|qbzd-bin)
qbzd version | grep -F "qbzd ${VERSION} " 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 qbzd completions bash >/dev/null
test -f /usr/lib/systemd/user/qbzd.service test -f /usr/lib/systemd/user/qbzd.service
qbzd service systemd --user builder --bin /usr/bin/qbzd \ qbzd service systemd --user builder --bin /usr/bin/qbzd \