From 452589ddaac331a3a6caf7b56fe190baef1adeb0 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:34:23 -0600 Subject: [PATCH] =?UTF-8?q?feat(qt):=20open=20a=20SACD=20image=20and=20pla?= =?UTF-8?q?y=20it=20=E2=80=94=20the=20disc=20names=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Open > Open SACD image…` picks a .iso, reads its stereo area and publishes it as the ephemeral session. Nothing lands in library.db. Unlike a CD, a SACD needs no network to be named: the Master TOC carries the album title and artist, and the area TOC a title per track. The owner's disc opens as "Wagner: Das Rheingold", 24 tracks, every one with its German title and "Berliner Philharmoniker, Herbert von Karajan, etc." — all of it read off the image. Routing is one line in `LocalSource::playback` and none in the player: a `sacd:` reference rides the existing DsdFile ticket, because `open_dsd` is the funnel all six player-side DSD constructions already go through. Teaching it there is what makes the PCM, DoP, native and gapless paths inherit disc images for free. Driven end to end on the owner's Rheingold: the tab reads "Wagner: Das Rheingold 24", the badges read HI-RES / DSD64, and pressing play took the DoP route — Player: DSD track — DSD64 via DoP (176400 Hz carrier) [ALSA Direct] DoP hardware configured: 176400Hz, 2ch, S32_LE Audio thread: DoP playback STARTED which is the outcome that matters: the disc's DSD bits reach the DAC without being converted to PCM at all. Six new strings in all eight locales. Unsupported cases are named rather than approximated — a multichannel-only area, a DST-compressed one and a file that is not an image each get their own message. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 18 ++ .../translations/en/LC_MESSAGES/qbz-ui.po | 18 ++ .../translations/es/LC_MESSAGES/qbz-ui.po | 18 ++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 18 ++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 18 ++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 18 ++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 18 ++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 18 ++ crates/qbz-qt/qml/views/local/LocalChrome.qml | 5 +- crates/qbz-qt/src/cdda_qt.rs | 219 ++++++++++++++---- crates/qbz-qt/src/local_bridge.rs | 25 +- crates/qbz-qt/src/local_ephemeral.rs | 105 +++++++-- crates/qbz-qt/src/main.rs | 1 + crates/qbz-qt/src/sacd_qt.rs | 99 ++++++++ crates/qbz-source/src/sources/local.rs | 9 + 15 files changed, 537 insertions(+), 70 deletions(-) create mode 100644 crates/qbz-qt/src/sacd_qt.rs 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 });