diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 544020861..ba3d2d3a9 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -5396,3 +5396,21 @@ msgstr "Die Disc konnte nicht gelesen werden." msgid "Track {}" msgstr "Titel {}" + +msgid "Open SACD image…" +msgstr "SACD-Image öffnen…" + +msgid "Choose a disc image" +msgstr "Disc-Image auswählen" + +msgid "That image has no stereo audio area." +msgstr "Dieses Image hat keinen Stereo-Audiobereich." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Diese Disc ist DST-komprimiert; QBZ kann sie noch nicht abspielen." + +msgid "That file is not a disc image." +msgstr "Diese Datei ist kein Disc-Image." + +msgid "SACD" +msgstr "SACD" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 60b47c0e4..ea0455540 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -941,3 +941,21 @@ msgstr "Could not read the disc." msgid "Track {}" msgstr "Track {}" + +msgid "Open SACD image…" +msgstr "Open SACD image…" + +msgid "Choose a disc image" +msgstr "Choose a disc image" + +msgid "That image has no stereo audio area." +msgstr "That image has no stereo audio area." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "This disc is DST-compressed, which QBZ cannot play yet." + +msgid "That file is not a disc image." +msgstr "That file is not a disc image." + +msgid "SACD" +msgstr "SACD" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 3caf6befd..6ff3f4856 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -5402,3 +5402,21 @@ msgstr "No se pudo leer el disco." msgid "Track {}" msgstr "Pista {}" + +msgid "Open SACD image…" +msgstr "Abrir imagen SACD…" + +msgid "Choose a disc image" +msgstr "Elige una imagen de disco" + +msgid "That image has no stereo audio area." +msgstr "Esa imagen no tiene área de audio estéreo." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Este disco está comprimido con DST y QBZ todavía no puede reproducirlo." + +msgid "That file is not a disc image." +msgstr "Ese archivo no es una imagen de disco." + +msgid "SACD" +msgstr "SACD" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 2e524c4dd..5977b14fd 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -5394,3 +5394,21 @@ msgstr "Impossible de lire le disque." msgid "Track {}" msgstr "Piste {}" + +msgid "Open SACD image…" +msgstr "Ouvrir une image SACD…" + +msgid "Choose a disc image" +msgstr "Choisir une image disque" + +msgid "That image has no stereo audio area." +msgstr "Cette image n'a pas de zone audio stéréo." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Ce disque est compressé en DST, que QBZ ne peut pas encore lire." + +msgid "That file is not a disc image." +msgstr "Ce fichier n'est pas une image disque." + +msgid "SACD" +msgstr "SACD" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 0888ba5b3..34134248f 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -5379,3 +5379,21 @@ msgstr "ディスクを読み取れませんでした。" msgid "Track {}" msgstr "トラック {}" + +msgid "Open SACD image…" +msgstr "SACDイメージを開く…" + +msgid "Choose a disc image" +msgstr "ディスクイメージを選択" + +msgid "That image has no stereo audio area." +msgstr "このイメージにはステレオ領域がありません。" + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "このディスクはDST圧縮のため、QBZではまだ再生できません。" + +msgid "That file is not a disc image." +msgstr "そのファイルはディスクイメージではありません。" + +msgid "SACD" +msgstr "SACD" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index ed373bcb0..223abfc47 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -5384,3 +5384,21 @@ msgstr "Kon de schijf niet lezen." msgid "Track {}" msgstr "Track {}" + +msgid "Open SACD image…" +msgstr "SACD-image openen…" + +msgid "Choose a disc image" +msgstr "Kies een schijfimage" + +msgid "That image has no stereo audio area." +msgstr "Die image heeft geen stereo-audiogebied." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Deze schijf is DST-gecomprimeerd; QBZ kan die nog niet afspelen." + +msgid "That file is not a disc image." +msgstr "Dat bestand is geen schijfimage." + +msgid "SACD" +msgstr "SACD" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index c5ad61474..fd919a6ca 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -5394,3 +5394,21 @@ msgstr "Não foi possível ler o disco." msgid "Track {}" msgstr "Faixa {}" + +msgid "Open SACD image…" +msgstr "Abrir imagem SACD…" + +msgid "Choose a disc image" +msgstr "Escolha uma imagem de disco" + +msgid "That image has no stereo audio area." +msgstr "Essa imagem não tem área de áudio estéreo." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Este disco está comprimido em DST, que o QBZ ainda não reproduz." + +msgid "That file is not a disc image." +msgstr "Esse arquivo não é uma imagem de disco." + +msgid "SACD" +msgstr "SACD" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 229c0b3e3..c1e238920 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -5413,3 +5413,21 @@ msgstr "Не удалось прочитать диск." msgid "Track {}" msgstr "Дорожка {}" + +msgid "Open SACD image…" +msgstr "Открыть образ SACD…" + +msgid "Choose a disc image" +msgstr "Выберите образ диска" + +msgid "That image has no stereo audio area." +msgstr "В этом образе нет стереообласти." + +msgid "This disc is DST-compressed, which QBZ cannot play yet." +msgstr "Диск сжат в DST — QBZ пока не может его воспроизвести." + +msgid "That file is not a disc image." +msgstr "Это не образ диска." + +msgid "SACD" +msgstr "SACD" diff --git a/crates/qbz-qt/qml/views/local/LocalChrome.qml b/crates/qbz-qt/qml/views/local/LocalChrome.qml index bede1c212..4f9327c55 100644 --- a/crates/qbz-qt/qml/views/local/LocalChrome.qml +++ b/crates/qbz-qt/qml/views/local/LocalChrome.qml @@ -197,11 +197,14 @@ Item { { "label": QbzSession.tr("Open folder…", QbzSession.trRev), "icon": "folder-open", "action": "folder" }, { "label": QbzSession.tr("Open audio CD", QbzSession.trRev), - "icon": "disc", "action": "cd" } + "icon": "disc", "action": "cd" }, + { "label": QbzSession.tr("Open SACD image…", QbzSession.trRev), + "icon": "disc-3", "action": "sacd" } ] onPicked: function (action) { if (action === "folder") QbzLocal.ephemeralOpen() else if (action === "cd") QbzLocal.ephemeralOpenCd() + else if (action === "sacd") QbzLocal.ephemeralOpenSacd() } } diff --git a/crates/qbz-qt/src/cdda_qt.rs b/crates/qbz-qt/src/cdda_qt.rs index ac32b0706..ab1217100 100644 --- a/crates/qbz-qt/src/cdda_qt.rs +++ b/crates/qbz-qt/src/cdda_qt.rs @@ -51,54 +51,104 @@ fn to_local_track(track: &qbz_disc::cdda::TocTrack, album: &str) -> LocalTrack { } } -/// Read the disc in the first drive that has one and publish it as the -/// ephemeral session. Returns the number of audio tracks, or an error string -/// already translated for a toast. -pub fn open_disc() -> Result { +/// What MusicBrainz knows about a disc, reduced to what a track list needs. +#[derive(Debug, Default)] +pub struct DiscMeta { + pub album: Option, + pub artist: Option, + pub year: Option, + /// Track titles in disc order. Shorter than the disc if MusicBrainz and + /// the drive disagree about the track count — the caller must index + /// defensively rather than assume alignment. + pub titles: Vec, +} + +/// Ask MusicBrainz what this disc is. +/// +/// Best effort by design: no network, a rate limit, an unknown disc or a +/// malformed answer all give `None`, and the caller keeps its honest +/// "Track N" names. A CD that plays with plain numbers is a small +/// disappointment; a CD labelled with the WRONG album is a bug the user has to +/// notice and undo. +async fn lookup_musicbrainz(disc_id: &str) -> Option { + // MusicBrainz requires a descriptive User-Agent and blocks clients that + // do not send one. It also rate-limits to ~1 request/second, which this + // respects by only ever asking once per disc open. + let client = reqwest::Client::builder() + .user_agent(concat!( + "QBZ/", + env!("CARGO_PKG_VERSION"), + " (https://github.com/vicrodh/qbz)" + )) + .timeout(std::time::Duration::from_secs(10)) + .build() + .ok()?; + + let body = client + .get(qbz_disc::discid::lookup_url(disc_id)) + .send() + .await + .ok()? + .json::() + .await + .ok()?; + + // The answer can list SEVERAL releases for one disc (different pressings + // of the same record). They share the geometry, so the first is as good as + // any for titles — and picking one is better than showing a chooser for a + // difference the user cannot hear. + let release = body.get("releases")?.as_array()?.first()?; + let media = release + .get("media")? + .as_array()? + .iter() + .find(|m| m.get("tracks").is_some())?; + + let titles: Vec = media + .get("tracks")? + .as_array()? + .iter() + .filter_map(|t| t.get("title")?.as_str().map(str::to_string)) + .collect(); + + let artist = release + .get("artist-credit") + .and_then(|a| a.as_array()) + .and_then(|a| a.first()) + .and_then(|a| a.get("name")) + .and_then(|n| n.as_str()) + .map(str::to_string); + + let year = release + .get("date") + .and_then(|d| d.as_str()) + .and_then(|d| d.get(0..4)) + .and_then(|y| y.parse::().ok()); + + Some(DiscMeta { + album: release + .get("title") + .and_then(|t| t.as_str()) + .map(str::to_string), + artist, + year, + titles, + }) +} + +/// Read the table of contents of the first drive that has an audio disc. +/// BLOCKING — spinning a drive up takes a second or two. +fn read_first_disc() -> Result<(std::path::PathBuf, qbz_disc::Toc), String> { let devices = qbz_disc::list_devices(); if devices.is_empty() { return Err(qbz_i18n::t("No optical drive found.")); } - - // Several drives are legal; take the first one that actually has an audio - // disc rather than assuming /dev/sr0 is the interesting one. + // Several drives are legal; take the first that actually HAS an audio disc + // rather than assuming /dev/sr0 is the interesting one. let mut last_err = None; for dev in &devices { match qbz_disc::read_toc(dev) { - Ok(toc) => { - let album = disc_label(); - let audio: Vec<_> = toc.audio_tracks().collect(); - let skipped = toc.tracks.len() - audio.len(); - if skipped > 0 { - // Mixed-mode disc. Say so rather than quietly showing - // fewer tracks than the case insert lists. - log::info!( - "[qbz-qt] cd: {skipped} data track(s) skipped on {}", - dev.display() - ); - } - let tracks: Vec = audio - .iter() - .map(|t| { - let mut lt = to_local_track(t, &album); - lt.file_path = qbz_disc::CdRef { - device: dev.clone(), - start_lsn: t.start_lsn, - sectors: t.sectors, - } - .to_path_string(); - lt - }) - .collect(); - let count = tracks.len(); - log::info!( - "[qbz-qt] cd: {} — {count} audio tracks, fingerprint {}", - dev.display(), - toc.fingerprint() - ); - local_ephemeral::adopt_tracks(&album, tracks); - return Ok(count); - } + Ok(toc) => return Ok((dev.clone(), toc)), Err(e) => { log::info!("[qbz-qt] cd: {} unusable: {e}", dev.display()); last_err = Some(e); @@ -113,6 +163,93 @@ pub fn open_disc() -> Result { }) } +/// Read the disc in the drive, name it if MusicBrainz knows it, and publish it +/// as the ephemeral session. Returns the number of audio tracks, or an error +/// string already translated for a toast. +pub async fn open_disc() -> Result { + let (dev, toc) = tokio::task::spawn_blocking(read_first_disc) + .await + .map_err(|e| format!("{e}"))??; + + let audio: Vec = toc.audio_tracks().cloned().collect(); + let skipped = toc.tracks.len() - audio.len(); + if skipped > 0 { + // Mixed-mode disc. Say so rather than quietly showing fewer tracks + // than the case insert lists. + log::info!( + "[qbz-qt] cd: {skipped} data track(s) skipped on {}", + dev.display() + ); + } + + // The Disc ID is computed from the AUDIO tracks' geometry, which is what + // MusicBrainz hashes. Failing to compute one (an empty or absurd disc) is + // not an error — it just means no names. + let starts: Vec = audio.iter().map(|t| t.start_lsn).collect(); + let meta = match qbz_disc::discid::disc_id(&starts, toc.leadout_lsn) { + Some(id) => { + log::info!("[qbz-qt] cd: disc id {id}"); + lookup_musicbrainz(&id).await + } + None => None, + } + .unwrap_or_default(); + + let album = meta + .album + .clone() + .filter(|a| !a.is_empty()) + .unwrap_or_else(disc_label); + if let Some(a) = meta.album.as_deref() { + log::info!( + "[qbz-qt] cd: identified as {:?} by {:?} ({} titles)", + a, + meta.artist.as_deref().unwrap_or("?"), + meta.titles.len() + ); + } else { + log::info!("[qbz-qt] cd: not identified — tracks keep their numbers"); + } + + let tracks: Vec = audio + .iter() + .enumerate() + .map(|(i, t)| { + let mut lt = to_local_track(t, &album); + lt.file_path = qbz_disc::CdRef { + device: dev.clone(), + start_lsn: t.start_lsn, + sectors: t.sectors, + } + .to_path_string(); + // Index defensively: MusicBrainz and the drive can disagree about + // the track count (a hidden track, a mixed-mode disc), and pairing + // them by position without checking is how track 5 gets track 6's + // name. + if let Some(title) = meta.titles.get(i).filter(|s| !s.is_empty()) { + lt.title = title.clone(); + } + if let Some(artist) = meta.artist.as_deref() { + lt.artist = artist.to_string(); + lt.album_artist = Some(artist.to_string()); + } + // `LocalTrack.year` is u32; a release date before year zero is not a + // thing, so a negative parse is simply dropped. + lt.year = meta.year.and_then(|y| u32::try_from(y).ok()); + lt + }) + .collect(); + + let count = tracks.len(); + log::info!( + "[qbz-qt] cd: {} — {count} audio tracks, fingerprint {}", + dev.display(), + toc.fingerprint() + ); + local_ephemeral::adopt_tracks(&album, tracks); + Ok(count) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 135c76336..64ccf38a2 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -300,6 +300,9 @@ pub mod qbz_local { /// no disc, a data-only disc) rather than opening an empty pane. #[qinvokable] fn ephemeral_open_cd(self: Pin<&mut QbzLocal>); + /// `Open > Open SACD image…`: pick a .iso and play its stereo area. + #[qinvokable] + fn ephemeral_open_sacd(self: Pin<&mut QbzLocal>); /// Header Play / Shuffle: the whole session becomes the queue. Same /// `(id, shuffle)` shape as `play_album`, so the two headers behave /// identically. @@ -1094,11 +1097,27 @@ impl qbz_local::QbzLocal { // Reading a TOC spins the drive up and can take a second or two, so it // does not run on the UI thread. crate::spawn(async move { - let outcome = tokio::task::spawn_blocking(crate::cdda_qt::open_disc).await; + match crate::cdda_qt::open_disc().await { + Ok(n) => log::info!("[qbz-qt] cd opened: {n} tracks"), + Err(msg) => crate::toast_qt::error(msg), + } + }); + } + + pub fn ephemeral_open_sacd(self: Pin<&mut Self>) { + crate::spawn(async move { + let picked = tokio::task::spawn_blocking(crate::local_ephemeral::pick_image_blocking) + .await + .ok() + .flatten(); + let Some(path) = picked else { return }; + let p = std::path::PathBuf::from(&path); + // Reading the area TOC seeks around a multi-gigabyte file. + let outcome = tokio::task::spawn_blocking(move || crate::sacd_qt::open_image(&p)).await; match outcome { - Ok(Ok(n)) => log::info!("[qbz-qt] cd opened: {n} tracks"), + Ok(Ok(n)) => log::info!("[qbz-qt] sacd opened: {n} tracks"), Ok(Err(msg)) => crate::toast_qt::error(msg), - Err(e) => log::warn!("[qbz-qt] cd open task failed: {e}"), + Err(e) => log::warn!("[qbz-qt] sacd open task failed: {e}"), } }); } diff --git a/crates/qbz-qt/src/local_ephemeral.rs b/crates/qbz-qt/src/local_ephemeral.rs index d042b103d..b1159de90 100644 --- a/crates/qbz-qt/src/local_ephemeral.rs +++ b/crates/qbz-qt/src/local_ephemeral.rs @@ -473,6 +473,85 @@ async fn wipe_if_playing(runtime: &Runtime) { /// Ask the desktop for a folder. Returns None on cancel AND when no chooser is /// installed (logged, so a dead button is never silent). BLOCKING — the caller /// runs it on `spawn_blocking`. +/// File chooser for a disc IMAGE. Same four-chooser ladder as the folder +/// picker — the difference is a file selection with an `*.iso` filter, and a +/// start directory of Downloads rather than Music, because that is where a +/// downloaded image lands. +pub(crate) fn pick_image_blocking() -> Option { + let title = qbz_i18n::t("Choose a disc image"); + let start = dirs::download_dir() + .or_else(dirs::home_dir) + .unwrap_or_else(|| PathBuf::from("/")); + let start = start.to_string_lossy().into_owned(); + let candidates: [(&str, Vec); 4] = [ + ( + "zenity", + vec![ + "--file-selection".into(), + format!("--title={title}"), + format!("--filename={start}/"), + "--file-filter=Disc images (*.iso) | *.iso *.ISO".into(), + ], + ), + ( + "qarma", + vec![ + "--file-selection".into(), + format!("--title={title}"), + format!("--filename={start}/"), + "--file-filter=Disc images (*.iso) | *.iso *.ISO".into(), + ], + ), + ( + "kdialog", + vec![ + "--getopenfilename".into(), + start.clone(), + "*.iso *.ISO|Disc images".into(), + "--title".into(), + title.clone(), + ], + ), + ( + "yad", + vec![ + "--file".into(), + format!("--title={title}"), + "--file-filter=Disc images | *.iso *.ISO".into(), + ], + ), + ]; + run_chooser(&candidates) +} + +/// The shared "run the first chooser that exists" loop. +fn run_chooser(candidates: &[(&str, Vec)]) -> Option { + for (bin, args) in candidates { + match std::process::Command::new(bin).args(args.iter()).output() { + // The chooser ran: a path on stdout, or an empty/failed exit = + // the user cancelled. Either way we stop looking. + Ok(out) => { + if !out.status.success() { + return None; + } + let picked = String::from_utf8_lossy(&out.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + return (!picked.is_empty()).then_some(picked); + } + // Not installed — try the next one. + Err(_) => continue, + } + } + log::error!( + "[qbz-qt] ephemeral: no chooser found (zenity / qarma / kdialog / yad)" + ); + None +} + fn pick_folder_blocking() -> Option { let title = qbz_i18n::t("Choose a folder to play"); let start = dirs::audio_dir() @@ -518,31 +597,7 @@ fn pick_folder_blocking() -> Option { ], ), ]; - for (bin, args) in candidates { - match std::process::Command::new(bin).args(&args).output() { - // The chooser ran: a path on stdout, or an empty/failed exit = - // the user cancelled. Either way we stop looking. - Ok(out) => { - if !out.status.success() { - return None; - } - let picked = String::from_utf8_lossy(&out.stdout) - .lines() - .next() - .unwrap_or("") - .trim() - .to_string(); - return (!picked.is_empty()).then_some(picked); - } - // Not installed — try the next one. - Err(_) => continue, - } - } - log::error!( - "[qbz-qt] ephemeral: no folder chooser found (zenity / qarma / kdialog / yad) — \ - call ephemeralOpenPath(path) with a known folder instead" - ); - None + run_chooser(&candidates) } // --------------------------------------------------------------------------- diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 7ab5474cc..916f826d1 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -240,6 +240,7 @@ mod nav_qt; mod browse_qt; mod cast_qt; mod cdda_qt; +mod sacd_qt; mod label_qt; mod now_playing; mod output_labels; diff --git a/crates/qbz-qt/src/sacd_qt.rs b/crates/qbz-qt/src/sacd_qt.rs new file mode 100644 index 000000000..a9ac378c0 --- /dev/null +++ b/crates/qbz-qt/src/sacd_qt.rs @@ -0,0 +1,99 @@ +//! Open a SACD disc image as an ephemeral session. +//! +//! Unlike a CD, a SACD names itself: the Master TOC carries the album title +//! and artist, and the area TOC carries a title per track. So this asks no +//! network and invents nothing — every string below came off the disc. +//! +//! Scope, and it is stated in the UI rather than hidden: the STEREO area of an +//! uncompressed image. A multichannel-only disc, a DST-compressed area or an +//! image with no ISO 9660 layer is REPORTED, never approximated into silence. + +use qbz_library::{AudioFormat, LocalTrack}; + +use crate::local_ephemeral; + +/// Read an image and publish its stereo area as the ephemeral session. +/// Returns the track count, or an already-translated error for a toast. +pub fn open_image(path: &std::path::Path) -> Result { + let area = qbz_disc::sacd::read_area(path).map_err(|e| { + log::warn!("[qbz-qt] sacd: {} unusable: {e}", path.display()); + match e { + qbz_disc::sacd::SacdError::NoStereoArea => { + qbz_i18n::t("That image has no stereo audio area.") + } + qbz_disc::sacd::SacdError::Dst => { + qbz_i18n::t("This disc is DST-compressed, which QBZ cannot play yet.") + } + qbz_disc::sacd::SacdError::Iso(_) => qbz_i18n::t("That file is not a disc image."), + other => format!("{other}"), + } + })?; + + // The album name comes off the disc; the file name is only a fallback for + // an image whose Master TOC carries no text. + let album = area.album.clone().unwrap_or_else(|| { + path.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| qbz_i18n::t("SACD")) + }); + + let artist = area.artist.clone().filter(|a| !a.is_empty()); + let tracks: Vec = area + .tracks + .iter() + .map(|t| LocalTrack { + title: t + .title + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| qbz_i18n::t_args("Track {}", &[&t.number.to_string()])), + album: album.clone(), + album_group_title: album.clone(), + album_group_key: format!("sacd|||{album}"), + artist: artist.clone().unwrap_or_default(), + album_artist: artist.clone(), + track_number: Some(t.number as u32), + disc_number: Some(1), + duration_secs: t.duration_secs as u64, + // DSD64 stereo. `bit_depth` is the format's nominal 1, and the + // rate is the DSD bit rate — the same shape a .dsf row carries, + // which is what makes the quality badge read DSD64 rather than + // inventing a tier for discs. + sample_rate: 2_822_400.0, + bit_depth: Some(1), + format: AudioFormat::Dsd, + file_path: qbz_disc::SacdRef { + image: path.to_path_buf(), + track: t.number, + } + .to_path_string(), + ..Default::default() + }) + .collect(); + + let count = tracks.len(); + log::info!( + "[qbz-qt] sacd: {} — {count} tracks, {:.0}s, album {:?}", + path.display(), + area.total_playtime_secs, + album + ); + local_ephemeral::adopt_tracks(&album, tracks); + Ok(count) +} + +#[cfg(test)] +mod tests { + #[test] + fn a_sacd_row_carries_the_shape_a_dsd_badge_expects() { + // The badge reads (format, depth, rate). A disc track has to look + // exactly like a .dsf row here, or Local Library would need a second + // rule for discs — and a second rule is a second thing to forget. + let r = qbz_disc::SacdRef { + image: std::path::PathBuf::from("/m/d.iso"), + track: 4, + }; + assert_eq!(r.to_path_string(), "sacd:/m/d.iso#4"); + assert!(qbz_disc::SacdRef::is_sacd_path(&r.to_path_string())); + } +} diff --git a/crates/qbz-source/src/sources/local.rs b/crates/qbz-source/src/sources/local.rs index b031b3d3b..f86a837cf 100644 --- a/crates/qbz-source/src/sources/local.rs +++ b/crates/qbz-source/src/sources/local.rs @@ -779,6 +779,15 @@ impl Source for LocalSource { }; } + // A SACD track rides the DSD ticket unchanged: `open_dsd` recognises + // the reference and returns a demuxer, so the player's PCM, DoP and + // native paths all inherit disc images with no arm of their own. + // Answered before the extension tests because a `sacd:` string is not + // a path and must never be opened as one. + if qbz_disc::SacdRef::is_sacd_path(&row.file_path) { + return Ok(PlaybackTicket::DsdFile { path, play_id }); + } + let lower = row.file_path.to_lowercase(); if lower.ends_with(".dsf") || lower.ends_with(".dff") { return Ok(PlaybackTicket::DsdFile { path, play_id });