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:
7 files changed
+71
-11
No files matched your search
@@ -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')"),
|
||||||
|
|||||||
@@ -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<()> {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}"#);
|
||||||
|
|||||||
Reference in new issue
Block a user