diff --git a/crates/qbz-library/src/database.rs b/crates/qbz-library/src/database.rs index b99190682..57401ba8c 100644 --- a/crates/qbz-library/src/database.rs +++ b/crates/qbz-library/src/database.rs @@ -3309,6 +3309,18 @@ impl LibraryDatabase { } /// Parse format string to AudioFormat + /// Inverse of `AudioFormat`'s `Display` (`models.rs:24`), which is what + /// the scanner stores. It MUST answer every variant that `Display` can + /// write: an unlisted one folds to `Unknown` silently and the format is + /// destroyed on the way out of the DB, not on the way in. + /// + /// `"DSD"` was exactly that hole. `MetadataExtractor::detect_format` has + /// mapped `.dsf`/`.dff` to `AudioFormat::Dsd` since DSD landed + /// (`metadata.rs:824`) and the rows on disk say `DSD` — but every read + /// handed back `Unknown`, so a DSD track showed "UNKNOWN" as its format, + /// took the CD quality badge (depth 1 is a KNOWN depth < 24) and printed + /// "1-bit / 2822.4 kHz". The now-playing bar was right all along because + /// it reads the decoder, not the row. fn parse_format(s: &str) -> AudioFormat { match s.to_uppercase().as_str() { "FLAC" => AudioFormat::Flac, @@ -3317,6 +3329,7 @@ impl LibraryDatabase { "AIFF" => AudioFormat::Aiff, "APE" => AudioFormat::Ape, "MP3" => AudioFormat::Mp3, + "DSD" => AudioFormat::Dsd, _ => AudioFormat::Unknown, } } @@ -7400,3 +7413,55 @@ mod remote_union_tests { ); } } + +#[cfg(test)] +mod audio_format_roundtrip_tests { + use super::*; + + /// `parse_format` is the inverse of `AudioFormat`'s `Display`, and the + /// scanner writes exactly what `Display` produces. This asserts the pair + /// for EVERY variant rather than for one format, because the defect it + /// guards is structural: `"DSD"` had no arm, so every DSD row read back + /// as `Unknown` — the format printed "UNKNOWN", the quality badge said CD + /// and the detail said "1-bit / 2822.4 kHz". A fold to `Unknown` is a + /// VALID answer, which is why nothing else caught it. + /// + /// Adding a variant to `AudioFormat` without an arm here fails this test + /// instead of shipping the same silent loss again. + #[test] + fn every_variant_survives_the_display_parse_round_trip() { + let all = [ + AudioFormat::Flac, + AudioFormat::Alac, + AudioFormat::Wav, + AudioFormat::Aiff, + AudioFormat::Ape, + AudioFormat::Mp3, + AudioFormat::Dsd, + ]; + for f in all { + let written = f.to_string(); + let read_back = LibraryDatabase::parse_format(&written); + assert_eq!( + read_back, f, + "AudioFormat::{f:?} is stored as {written:?} and read back as \ + {read_back:?} — parse_format is missing its arm" + ); + } + } + + /// The `Unknown` fold stays reachable for genuinely unknown words; it just + /// must not be where a KNOWN format lands. + #[test] + fn an_unknown_word_still_folds_to_unknown() { + assert_eq!(LibraryDatabase::parse_format("opus"), AudioFormat::Unknown); + assert_eq!(LibraryDatabase::parse_format(""), AudioFormat::Unknown); + } + + /// The scanner's own casing must not matter (rows written by older builds). + #[test] + fn parsing_is_case_insensitive() { + assert_eq!(LibraryDatabase::parse_format("dsd"), AudioFormat::Dsd); + assert_eq!(LibraryDatabase::parse_format("Dsd"), AudioFormat::Dsd); + } +} diff --git a/crates/qbz-qt/src/local_plex.rs b/crates/qbz-qt/src/local_plex.rs index 95563cdb6..179b09059 100644 --- a/crates/qbz-qt/src/local_plex.rs +++ b/crates/qbz-qt/src/local_plex.rs @@ -303,6 +303,11 @@ pub fn thumb_url(path: &str, size: Option) -> String { // Cache reads, mapped into the local-library shapes // --------------------------------------------------------------------------- +/// Plex reports a codec/container word. The DSD arm is not decoration: the +/// identical omission in `LibraryDatabase::parse_format` is what made every +/// local DSD track read back as `Unknown`, print "UNKNOWN" as its format and +/// wear the CD badge. A Plex server serving DSD hits the same fold, and a fold +/// to `Unknown` is a VALID answer — which is why no test ever sees it. fn parse_audio_format(s: &str) -> qbz_library::AudioFormat { use qbz_library::AudioFormat; match s.to_ascii_lowercase().as_str() { @@ -312,6 +317,7 @@ fn parse_audio_format(s: &str) -> qbz_library::AudioFormat { "aiff" | "aif" => AudioFormat::Aiff, "ape" => AudioFormat::Ape, "mp3" => AudioFormat::Mp3, + "dsd" | "dsf" | "dff" => AudioFormat::Dsd, _ => AudioFormat::Unknown, } } diff --git a/crates/qbz-qt/src/media_servers_qt.rs b/crates/qbz-qt/src/media_servers_qt.rs index 35882abb1..96720d452 100644 --- a/crates/qbz-qt/src/media_servers_qt.rs +++ b/crates/qbz-qt/src/media_servers_qt.rs @@ -369,7 +369,15 @@ fn parse_audio_format(container: &str, codec: Option<&str>) -> qbz_library::Audi use qbz_library::AudioFormat as F; let probe = |s: &str| -> Option { let s = s.to_ascii_lowercase(); - if s.contains("flac") { + // DSD first, and it matters MORE here than in the sibling parsers: + // this one falls back to `Flac`, not `Unknown`, so an unrecognised DSD + // track from Jellyfin/Navidrome does not merely lose its format — it + // is actively mislabelled lossless-PCM, with a "24-bit / …" badge it + // never had. Jellyfin reports `Container: "dsf"`, Subsonic a `suffix` + // of `dsf`/`dff` and a MIME of `audio/x-dsf`. + if s.contains("dsd") || s.contains("dsf") || s.contains("dff") { + Some(F::Dsd) + } else if s.contains("flac") { Some(F::Flac) } else if s.contains("mp3") || s.contains("mpeg") { Some(F::Mp3)