feat(local-library): DSD quality chip

DSD gets its own Quality chip and Hi-Res goes back to 24-bit PCM only, in
the MediaFilter, the native catalog SQL (tracks CASE and the materialized
album quality_tier) and the legacy QML filters. A Format chip would be the
same predicate, so there is none. Schema version 6 re-derives the album
tiers that were materialized as hires.

cc
This commit is contained in:
vicrodh committed 2026-09-02 20:34:59 -06:00
1 parent 35ff1818f6
commit baf0dc2bb9
7 files changed
+71 -11

No files matched your search

+4 -2
View File
@@ -637,7 +637,7 @@ impl Catalog {
WHEN MAX(CASE WHEN LOWER(t.format)='mp3' THEN 0 ELSE 1 END)=0 WHEN MAX(CASE WHEN LOWER(t.format)='mp3' THEN 0 ELSE 1 END)=0
THEN 'mp3' THEN 'mp3'
WHEN MAX(CASE WHEN LOWER(t.format) IN ('dsd','dsf','dff') THEN 1 ELSE 0 END)=1 WHEN MAX(CASE WHEN LOWER(t.format) IN ('dsd','dsf','dff') THEN 1 ELSE 0 END)=1
THEN 'hires' THEN 'dsd'
WHEN MAX(t.bit_depth)>=24 AND MAX(t.sample_rate_hz)>96000 THEN 'max' WHEN MAX(t.bit_depth)>=24 AND MAX(t.sample_rate_hz)>96000 THEN 'max'
WHEN MAX(t.bit_depth)>=24 THEN 'hires' WHEN MAX(t.bit_depth)>=24 THEN 'hires'
WHEN MAX(t.bit_depth) IS NOT NULL THEN 'cd' WHEN MAX(t.bit_depth) IS NOT NULL THEN 'cd'
@@ -1750,7 +1750,7 @@ fn filter_parts(
let tier = format!( let tier = format!(
"CASE \ "CASE \
WHEN LOWER({alias}.format)='mp3' THEN 'mp3' \ WHEN LOWER({alias}.format)='mp3' THEN 'mp3' \
WHEN LOWER({alias}.format) IN ('dsd','dsf','dff') THEN 'hires' \ WHEN LOWER({alias}.format) IN ('dsd','dsf','dff') THEN 'dsd' \
WHEN {alias}.bit_depth>=24 AND {alias}.sample_rate_hz>96000 THEN 'max' \ WHEN {alias}.bit_depth>=24 AND {alias}.sample_rate_hz>96000 THEN 'max' \
WHEN {alias}.bit_depth>=24 THEN 'hires' \ WHEN {alias}.bit_depth>=24 THEN 'hires' \
WHEN {alias}.bit_depth IS NOT NULL THEN 'cd' \ WHEN {alias}.bit_depth IS NOT NULL THEN 'cd' \
@@ -1760,6 +1760,7 @@ fn filter_parts(
let mut quality_arms = Vec::new(); let mut quality_arms = Vec::new();
for quality in descriptor.quality_tiers() { for quality in descriptor.quality_tiers() {
quality_arms.push(match quality.as_str() { quality_arms.push(match quality.as_str() {
"dsd" => format!("({tier})='dsd'"),
"hires" => format!("({tier}) IN ('hires','max')"), "hires" => format!("({tier}) IN ('hires','max')"),
"cd" => format!("({tier})='cd'"), "cd" => format!("({tier})='cd'"),
"lossy" => format!("({tier}) IN ('mp3','lossy')"), "lossy" => format!("({tier}) IN ('mp3','lossy')"),
@@ -2207,6 +2208,7 @@ fn album_filter_parts(
let mut quality = Vec::new(); let mut quality = Vec::new();
for tier in descriptor.quality_tiers() { for tier in descriptor.quality_tiers() {
match tier.as_str() { match tier.as_str() {
"dsd" => quality.push("am.quality_tier='dsd'"),
"hires" => quality.push("am.quality_tier IN ('hires','max')"), "hires" => quality.push("am.quality_tier IN ('hires','max')"),
"cd" => quality.push("am.quality_tier='cd'"), "cd" => quality.push("am.quality_tier='cd'"),
"lossy" => quality.push("am.quality_tier IN ('mp3','lossy')"), "lossy" => quality.push("am.quality_tier IN ('mp3','lossy')"),
+4 -1
View File
@@ -5,7 +5,10 @@ use crate::{CatalogError, Result};
// Version 5 changes the derived artist-identity projection. Existing v4 // Version 5 changes the derived artist-identity projection. Existing v4
// catalogs are rebuilt side-by-side from their authoritative caches; no user // catalogs are rebuilt side-by-side from their authoritative caches; no user
// library database is migrated in place. // library database is migrated in place.
pub const SCHEMA_VERSION: u32 = 5; // Version 6 gives DSD its own materialized album `quality_tier` ('dsd', no
// longer folded into 'hires') so the Local Library DSD chip can read it; the
// same side-by-side rebuild from the caches re-derives every album row.
pub const SCHEMA_VERSION: u32 = 6;
pub const APPLICATION_ID: i64 = 0x5142_5A43; // "QBZC" pub const APPLICATION_ID: i64 = 0x5142_5A43; // "QBZC"
pub(crate) fn configure(conn: &Connection) -> Result<()> { pub(crate) fn configure(conn: &Connection) -> Result<()> {
+18 -1
View File
@@ -617,7 +617,7 @@ fn album_fts_filters_and_descriptor_bound_cursors_are_exact() {
catalog.count_albums(&broad).unwrap() catalog.count_albums(&broad).unwrap()
); );
let filter_cases: [(QueryDescriptor, fn(&AlbumRecord) -> bool); 4] = [ let filter_cases: [(QueryDescriptor, fn(&AlbumRecord) -> bool); 5] = [
( (
QueryDescriptor::albums().with_source_buckets(vec!["plex".to_string()]), QueryDescriptor::albums().with_source_buckets(vec!["plex".to_string()]),
|row: &AlbumRecord| row.source == SourceKind::Plex, |row: &AlbumRecord| row.source == SourceKind::Plex,
@@ -636,6 +636,12 @@ fn album_fts_filters_and_descriptor_bound_cursors_are_exact() {
QueryDescriptor::albums().with_quality_tiers(vec!["hires".to_string()]), QueryDescriptor::albums().with_quality_tiers(vec!["hires".to_string()]),
|row: &AlbumRecord| matches!(row.quality_tier.as_str(), "hires" | "max"), |row: &AlbumRecord| matches!(row.quality_tier.as_str(), "hires" | "max"),
), ),
// DSD is a tier of its own: an album holding a DSD copy is 'dsd',
// never folded into hires.
(
QueryDescriptor::albums().with_quality_tiers(vec!["dsd".to_string()]),
|row: &AlbumRecord| row.quality_tier == "dsd",
),
]; ];
for (descriptor, predicate) in filter_cases { for (descriptor, predicate) in filter_cases {
let rows = collect_all_albums(&catalog, &descriptor, 29); let rows = collect_all_albums(&catalog, &descriptor, 29);
@@ -1211,6 +1217,17 @@ fn track_source_bucket_quality_and_other_format_filters_match_the_ui_funnel() {
catalog.count_tracks(&other).unwrap(), catalog.count_tracks(&other).unwrap(),
other_rows.len() as u64 other_rows.len() as u64
); );
// The DSD quality chip is the fixture's dsf rows exactly, and Hi-Res no
// longer folds them in.
let dsd = QueryDescriptor::tracks().with_quality_tiers(vec!["dsd".to_string()]);
let dsd_rows = collect_all(&catalog, &dsd);
assert_eq!(dsd_rows.len(), other_rows.len());
assert!(dsd_rows.iter().all(|row| row.format == "dsf"));
let hires = QueryDescriptor::tracks().with_quality_tiers(vec!["hires".to_string()]);
assert!(collect_all(&catalog, &hires)
.iter()
.all(|row| row.format != "dsf"));
} }
#[test] #[test]
+6 -4
View File
@@ -407,8 +407,8 @@ Rectangle {
{ group: QbzSession.tr("Favorites", tr), { group: QbzSession.tr("Favorites", tr),
values: pick(["favorite"], [QbzSession.tr("Favorites only", tr)]) }, values: pick(["favorite"], [QbzSession.tr("Favorites only", tr)]) },
{ group: QbzSession.tr("Quality", tr), { group: QbzSession.tr("Quality", tr),
values: pick(["hires", "cd", "lossy"], values: pick(["dsd", "hires", "cd", "lossy"],
[QbzSession.tr("Hi-Res", tr), QbzSession.tr("CD", tr), ["DSD", QbzSession.tr("Hi-Res", tr), QbzSession.tr("CD", tr),
QbzSession.tr("Lossy", tr)]) }, QbzSession.tr("Lossy", tr)]) },
{ group: QbzSession.tr("Format", tr), { group: QbzSession.tr("Format", tr),
values: pick(["flac", "alac", "ape", "wav", "mp3", "aac", "other"], values: pick(["flac", "alac", "ape", "wav", "mp3", "aac", "other"],
@@ -785,7 +785,8 @@ Rectangle {
var tier = (r.qualityTier || "").toLowerCase() var tier = (r.qualityTier || "").toLowerCase()
var fmt = (r.format || "").toLowerCase() var fmt = (r.format || "").toLowerCase()
if (qAny) { if (qAny) {
var qok = (selected.hires && (tier === "hires" || tier === "max" || tier === "dsd")) var qok = (selected.dsd && tier === "dsd")
|| (selected.hires && (tier === "hires" || tier === "max"))
|| (selected.cd && tier === "cd") || (selected.cd && tier === "cd")
|| (selected.lossy && (tier === "mp3" || tier === "lossy")) || (selected.lossy && (tier === "mp3" || tier === "lossy"))
if (!qok) continue if (!qok) continue
@@ -897,7 +898,8 @@ Rectangle {
var qualityOk = false var qualityOk = false
for (i = 0; i < (artist.qualityTiers || []).length; i++) { for (i = 0; i < (artist.qualityTiers || []).length; i++) {
value = (artist.qualityTiers[i] || "").toLowerCase() value = (artist.qualityTiers[i] || "").toLowerCase()
if ((selected.hires && (value === "hires" || value === "max")) if ((selected.dsd && value === "dsd")
|| (selected.hires && (value === "hires" || value === "max"))
|| (selected.cd && value === "cd") || (selected.cd && value === "cd")
|| (selected.lossy && (value === "mp3" || value === "lossy"))) { || (selected.lossy && (value === "mp3" || value === "lossy"))) {
qualityOk = true; break qualityOk = true; break
@@ -146,6 +146,13 @@ Item {
Row { Row {
id: qualityRow id: qualityRow
spacing: 8 spacing: 8
// DSD is a tier of its own (the badge already wears one);
// Hi-Res is 24-bit PCM only. Proper name, not translated.
FilterChip {
label: "DSD"
active: root.view.filter.dsd === true
onToggled: root.view.toggleFilter("dsd")
}
FilterChip { FilterChip {
label: QbzSession.tr("Hi-Res", QbzSession.trRev) label: QbzSession.tr("Hi-Res", QbzSession.trRev)
active: root.view.filter.hires === true active: root.view.filter.hires === true
+1 -1
View File
@@ -999,7 +999,7 @@ fn descriptor(search: String, sort: &str, group: &str, filter_json: &str) -> Que
.filter(|key| on(key)) .filter(|key| on(key))
.map(str::to_string) .map(str::to_string)
.collect(); .collect();
let qualities = ["hires", "cd", "lossy"] let qualities = ["dsd", "hires", "cd", "lossy"]
.into_iter() .into_iter()
.filter(|key| on(key)) .filter(|key| on(key))
.map(str::to_string) .map(str::to_string)
+31 -2
View File
@@ -22,7 +22,7 @@ impl MediaFilter {
.map(str::to_string) .map(str::to_string)
.collect(), .collect(),
other_formats: on("other"), other_formats: on("other"),
qualities: ["hires", "cd", "lossy"] qualities: ["dsd", "hires", "cd", "lossy"]
.into_iter() .into_iter()
.filter(|key| on(key)) .filter(|key| on(key))
.map(str::to_string) .map(str::to_string)
@@ -71,8 +71,11 @@ impl MediaFilter {
if !self.qualities.is_empty() { if !self.qualities.is_empty() {
let tier = let tier =
crate::local_rows::tier_of(&track.format, track.bit_depth, track.sample_rate); crate::local_rows::tier_of(&track.format, track.bit_depth, track.sample_rate);
// DSD is its own chip (the badge already wears its own tier);
// Hi-Res is 24-bit PCM only.
let quality = match tier { let quality = match tier {
"hires" | "max" | "dsd" => "hires", "dsd" => "dsd",
"hires" | "max" => "hires",
"cd" => "cd", "cd" => "cd",
_ => "lossy", _ => "lossy",
}; };
@@ -165,6 +168,32 @@ mod tests {
assert!(!sources.contains(&"plex")); assert!(!sources.contains(&"plex"));
} }
#[test]
fn dsd_chip_is_its_own_tier_and_hires_stays_pcm() {
let dsd = LocalTrack {
source: Some("user".into()),
format: AudioFormat::Dsd,
bit_depth: Some(1),
sample_rate: 2_822_400.0,
..Default::default()
};
let hires = LocalTrack {
source: Some("user".into()),
format: AudioFormat::Flac,
bit_depth: Some(24),
sample_rate: 96_000.0,
..Default::default()
};
let only_dsd = MediaFilter::from_json(r#"{"dsd":true}"#);
assert_eq!(only_dsd.qualities, ["dsd"]);
assert!(only_dsd.track_enabled(&dsd));
assert!(!only_dsd.track_enabled(&hires));
let only_hires = MediaFilter::from_json(r#"{"hires":true}"#);
assert!(only_hires.track_enabled(&hires));
assert!(!only_hires.track_enabled(&dsd), "DSD no longer hides under Hi-Res");
}
#[test] #[test]
fn local_source_chip_accepts_scanner_storage_word() { fn local_source_chip_accepts_scanner_storage_word() {
let filter = MediaFilter::from_json(r#"{"local":true}"#); let filter = MediaFilter::from_json(r#"{"local":true}"#);