feat(qt): a Rip button on an open CD, and it asks where to put the album

The button lives on the ephemeral pane's header and appears only for a
PHYSICAL disc — an image is already a file, so offering to rip one would be
offering to copy something the user already has. Whether the session is a disc
is DERIVED from its rows rather than passed in: a flag somebody has to
remember to set is a flag that eventually is not.

The destination is always asked. The app knows several plausible music folders,
which is exactly why it must not pick one — a ripper that decides where your
music goes is a ripper you clean up after.

While a rip runs the button gives way to its progress ("3/7 · 45%") and a
spinner: one control, one state, rather than a button that looks pressable
during a job it cannot start twice. The bar is per-DISC and not per-track,
because a listener watching a 79-minute album wants to know how far the ALBUM
is, and a per-track bar that resets seven times reads like a stall.

Driven end to end on the owner's Fear Inoculum: seven tracks written into
"Tool - Fear Inoculum/", every one decoding clean and seekable, all tagged,
no `.part` left behind.

Four new strings in all eight locales.

cc
This commit is contained in:
vicrodh committed 2026-08-21 02:27:40 -06:00
1 parent 5d3d51a882
commit bbce651d79
14 files changed
+338

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ log = { workspace = true }
# a transitive dep of half the tree; this only makes it direct.
libc = "0.2"
qbz-disc = { path = "../qbz-disc" }
qbz-rip = { path = "../qbz-rip" }
rand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -158,6 +158,34 @@ Item {
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.ephemeralPlayAll(true)
}
// Rip — a PHYSICAL disc only. An image is already a
// file, so offering it there would be offering to copy
// something the user already has.
QbzCircleAction {
visible: QbzLocal.localEphemeralIsCd && !QbzLocal.localRipActive
name: "cloud-download"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.ripDisc()
}
// While it runs the button gives way to its progress —
// one control, one state, rather than a button that
// looks pressable during a job it cannot start twice.
Row {
visible: QbzLocal.localRipActive
spacing: 8
anchors.verticalCenter: parent.verticalCenter
QbzSpinner {
anchors.verticalCenter: parent.verticalCenter
size: 15
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Ripping", QbzSession.trRev)
+ " " + QbzLocal.localRipProgress
color: theme.textSecondary
font.pixelSize: theme.fontLegal
}
}
// Kept: this is the ONLY way to close the session, and
// with it the only way to make the tab go away.
QbzCircleAction {
+24
View File
@@ -129,6 +129,16 @@ pub mod qbz_local {
/// sequence changes on every open, which is what "take me to what I
/// just opened" actually needs.
#[qproperty(i32, local_ephemeral_open_seq)]
/// The open session came from a physical CD, so it can be RIPPED.
/// Derived from the rows themselves rather than passed in — a flag
/// somebody has to remember to set is a flag that eventually is not.
#[qproperty(bool, local_ephemeral_is_cd)]
/// A rip is running; the pane shows progress instead of the button.
#[qproperty(bool, local_rip_active)]
/// "3/7 · 45%" — already formatted, because the number of things that
/// can disagree about how to format it is otherwise the number of
/// places that show it.
#[qproperty(QString, local_rip_progress)]
// --- Plex ----------------------------------------------------------
/// Master toggle (Settings > Local Library > Plex). Drives whether
@@ -303,6 +313,10 @@ pub mod qbz_local {
/// `Open > Open SACD image…`: pick a .iso and play its stereo area.
#[qinvokable]
fn ephemeral_open_sacd(self: Pin<&mut QbzLocal>);
/// Rip the open CD into a folder the user picks. No-op unless the
/// session is a disc.
#[qinvokable]
fn rip_disc(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.
@@ -486,6 +500,9 @@ pub struct QbzLocalRust {
local_ephemeral_json: QString,
local_ephemeral_label: QString,
local_ephemeral_open_seq: i32,
local_ephemeral_is_cd: bool,
local_rip_active: bool,
local_rip_progress: QString,
plex_enabled: bool,
plex_available: bool,
plex_syncing: bool,
@@ -536,6 +553,9 @@ impl Default for QbzLocalRust {
local_ephemeral_json: QString::from(""),
local_ephemeral_label: QString::from(""),
local_ephemeral_open_seq: 0,
local_ephemeral_is_cd: false,
local_rip_active: false,
local_rip_progress: QString::default(),
plex_enabled: false,
plex_available: false,
plex_syncing: false,
@@ -1122,6 +1142,10 @@ impl qbz_local::QbzLocal {
});
}
pub fn rip_disc(self: Pin<&mut Self>) {
crate::rip_qt::start();
}
pub fn ephemeral_play_all(self: Pin<&mut Self>, shuffle: bool) {
let runtime = crate::app();
crate::spawn(async move {
+40
View File
@@ -74,6 +74,35 @@ pub fn get_track(id: i64) -> Option<LocalTrack> {
STATE.get_track(id)
}
/// True when the open session is a physical CD — the only medium this app
/// can rip, since an image is already a file.
pub fn session_is_cd() -> bool {
STATE
.tracks_snapshot()
.first()
.map(|t| qbz_disc::CdRef::is_cd_path(&t.file_path))
.unwrap_or(false)
}
/// Where to put a rip. A folder chooser, and the answer is never guessed.
pub(crate) fn pick_folder_for_rip() -> Option<String> {
let title = qbz_i18n::t("Where should the ripped album go?");
let start = dirs::audio_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("/"));
let start = start.to_string_lossy().into_owned();
let candidates: [(&str, Vec<String>); 4] = [
("zenity", vec!["--file-selection".into(), "--directory".into(),
format!("--title={title}"), format!("--filename={start}/")]),
("qarma", vec!["--file-selection".into(), "--directory".into(),
format!("--title={title}"), format!("--filename={start}/")]),
("kdialog", vec!["--getexistingdirectory".into(), start.clone(),
"--title".into(), title.clone()]),
("yad", vec!["--file".into(), "--directory".into(), format!("--title={title}")]),
];
run_chooser(&candidates)
}
/// Every track of the current session, in scan (= display) order.
pub fn tracks_snapshot() -> Vec<LocalTrack> {
STATE.tracks_snapshot()
@@ -287,6 +316,7 @@ fn publish_closed() {
b.as_mut().set_local_ephemeral_loading(false);
b.as_mut().set_local_ephemeral_json(QString::from(""));
b.as_mut().set_local_ephemeral_label(QString::from(""));
b.as_mut().set_local_ephemeral_is_cd(false);
});
}
@@ -348,8 +378,18 @@ pub fn adopt_tracks(label: &str, tracks: Vec<LocalTrack>) {
ui(move |mut b| {
b.as_mut().set_local_ephemeral_open_seq(seq);
});
// Derived from the rows, not passed in: whoever builds the session
// already knows what the tracks are, and a separate flag is one more
// thing a future medium can forget to set.
let is_cd = tracks
.first()
.map(|t| qbz_disc::CdRef::is_cd_path(&t.file_path))
.unwrap_or(false);
match STATE.open_tracks(&label, tracks) {
Ok(res) => {
ui(move |mut b| {
b.as_mut().set_local_ephemeral_is_cd(is_cd);
});
log::info!(
"[qbz-qt] ephemeral adopted {} ({} tracks)",
label,
+1
View File
@@ -241,6 +241,7 @@ mod browse_qt;
mod cast_qt;
mod cdda_qt;
mod sacd_qt;
mod rip_qt;
mod label_qt;
mod now_playing;
mod output_labels;
+132
View File
@@ -0,0 +1,132 @@
//! "Rip this CD into my library": the Qt side of the job.
//!
//! Everything domain-shaped lives in `qbz-rip` (read, encode, tag, name); this
//! file only asks the user where to put it, turns the open session into a
//! plan, and reports progress.
//!
//! The DESTINATION is always asked, never guessed. A ripper that decides where
//! your music goes is a ripper you have to clean up after, and the app already
//! knows several plausible folders — which is exactly why it must not pick one.
use cxx_qt_lib::QString;
use crate::local_bridge::ui;
/// Kick off a rip of the currently open CD session.
pub fn start() {
if !crate::local_ephemeral::session_is_cd() {
return;
}
crate::spawn(async move {
let picked = tokio::task::spawn_blocking(crate::local_ephemeral::pick_folder_for_rip)
.await
.ok()
.flatten();
let Some(dest) = picked else { return };
let Some(plan) = build_plan(std::path::PathBuf::from(dest)) else {
crate::toast_qt::error(qbz_i18n::t("Nothing to rip."));
return;
};
let count = plan.tracks.len();
let album = plan.album.clone();
ui(|mut b| {
b.as_mut().set_local_rip_active(true);
b.as_mut().set_local_rip_progress(QString::from("0%"));
});
let outcome = tokio::task::spawn_blocking(move || {
qbz_rip::rip(&plan, |p| {
// The bar is per-DISC, not per-track: a listener watching a
// 79-minute album wants to know how far the album is, and a
// per-track bar that resets seven times reads like a stall.
let overall = (p.track_index as f32 + p.fraction) / p.track_count as f32;
let text = format!(
"{}/{} · {}%",
p.track_index + 1,
p.track_count,
(overall * 100.0) as u32
);
ui(move |mut b| {
b.as_mut().set_local_rip_progress(QString::from(text.as_str()));
});
true
})
})
.await;
ui(|mut b| {
b.as_mut().set_local_rip_active(false);
b.as_mut().set_local_rip_progress(QString::default());
});
match outcome {
Ok(Ok(files)) => {
log::info!("[qbz-qt] rip: {} files written", files.len());
crate::toast_qt::success(qbz_i18n::tf(
"Ripped {} track to your library",
"Ripped {} tracks to your library",
count as i64,
&[&count.to_string()],
));
// The folder is outside the index until somebody scans it.
// Saying so beats the user wondering why the album is not in
// Local Library yet.
log::info!("[qbz-qt] rip: {album:?} — run a folder scan to index it");
}
Ok(Err(qbz_rip::RipError::Cancelled)) => {
log::info!("[qbz-qt] rip cancelled");
}
Ok(Err(e)) => {
log::warn!("[qbz-qt] rip failed: {e}");
crate::toast_qt::error(format!("{e}"));
}
Err(e) => log::warn!("[qbz-qt] rip task failed: {e}"),
}
});
}
/// Turn the open session into a plan. Returns `None` when the session is not
/// a CD, or holds no track this can read.
fn build_plan(destination: std::path::PathBuf) -> Option<qbz_rip::RipPlan> {
let rows = crate::local_ephemeral::tracks_snapshot();
if rows.is_empty() {
return None;
}
let album = rows[0].album.clone();
let album_artist = rows[0]
.album_artist
.clone()
.filter(|a| !a.is_empty())
.unwrap_or_else(|| rows[0].artist.clone());
let tracks: Vec<qbz_rip::RipTrack> = rows
.iter()
.filter_map(|t| {
let r = qbz_disc::CdRef::parse(&t.file_path)?;
Some(qbz_rip::RipTrack {
number: t.track_number.unwrap_or(0),
title: t.title.clone(),
artist: if t.artist.is_empty() {
album_artist.clone()
} else {
t.artist.clone()
},
source: qbz_rip::RipSource::Cd {
device: r.device,
start_lsn: r.start_lsn,
sectors: r.sectors,
},
})
})
.collect();
(!tracks.is_empty()).then(|| qbz_rip::RipPlan {
destination,
album,
album_artist,
year: rows[0].year,
tracks,
})
}