5 Commits

Author SHA1 Message Date
5baadd9dbb Fix window icon and remove duplicate log 2026-04-18 23:39:20 +03:00
997bfef893 Reduce GUI vertical footprint 2026-04-18 23:33:42 +03:00
290d650f80 Respect system GTK theme 2026-04-18 23:27:22 +03:00
05838116d0 Make GUI more EAC-like 2026-04-18 23:22:04 +03:00
7aef00e66a Improve GUI action logging 2026-04-18 23:16:35 +03:00
3 changed files with 331 additions and 118 deletions

View File

@@ -1,5 +1,5 @@
pkgname=whipper-git
pkgver=0.10.0.r66.g992923b
pkgver=0.10.2.r0.g9564c7c
pkgrel=1
pkgdesc='CD-DA ripper prioritising accuracy over speed'
arch=('x86_64')

View File

@@ -36,6 +36,7 @@ from whipper.result import result
logger = logging.getLogger(__name__)
APP_ID = "com.github.whipper_team.Whipper"
class RipCancelledError(Exception):
@@ -86,7 +87,9 @@ def _release_duration_distance(metadata, duration_ms):
class WhipperGui(Gtk.Application if Gtk is not None else object):
def __init__(self):
require_gui_runtime()
super().__init__(application_id="com.github.whipper_team.WhipperGui")
super().__init__(application_id=APP_ID)
GLib.set_application_name("Whipper")
Gtk.Window.set_default_icon_name(APP_ID)
self.window = None
self.main_pane = None
@@ -122,6 +125,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
self.logger_combo = None
self.track_template_entry = None
self.disc_template_entry = None
self.compact_mode_label = None
self.release_store = None
self.release_view = None
@@ -129,6 +133,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
self.track_view = None
self.log_buffer = None
self.release_details = None
self.track_columns = None
self.info_labels = {}
self.status_label = None
@@ -148,36 +153,47 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
def _build_window(self):
window = Gtk.ApplicationWindow(application=self)
window.set_title("Whipper")
window.set_icon_name("com.github.whipper_team.Whipper")
window.set_default_size(1120, 760)
window.set_border_width(12)
window.set_icon_name(APP_ID)
window.set_default_size(1380, 820)
window.set_border_width(6)
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
window.add(root)
root.pack_start(self._build_controls(), False, False, 0)
root.pack_start(self._build_actions(), False, False, 0)
root.pack_start(self._build_transport_strip(), False, False, 0)
root.pack_start(self._build_progress(), False, False, 0)
root.pack_start(self._build_main_content(), True, True, 0)
root.pack_start(self._build_log(), True, True, 0)
self._refresh_devices()
self._load_gui_settings()
window.show_all()
return window
def _build_transport_strip(self):
frame = Gtk.Frame(label="Extraction Control Center")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=6)
frame.add(box)
box.pack_start(self._build_controls(), False, False, 0)
box.pack_start(self._build_actions(), False, False, 0)
return frame
def _build_controls(self):
grid = Gtk.Grid(column_spacing=10, row_spacing=10)
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
grid.attach(Gtk.Label(label="Drive", xalign=0), 0, 0, 1, 1)
self.device_combo = Gtk.ComboBoxText()
self.device_combo.connect("changed", self._on_device_changed)
grid.attach(self.device_combo, 1, 0, 2, 1)
grid.attach(self.device_combo, 1, 0, 1, 1)
self.refresh_button = Gtk.Button(label="Refresh Drives")
self.refresh_button = Gtk.Button(label="Detect Drives")
self.refresh_button.connect("clicked", self._on_refresh_clicked)
self.refresh_button.set_tooltip_text("Refresh the list of detected optical drives")
grid.attach(self.refresh_button, 3, 0, 1, 1)
grid.attach(self.refresh_button, 2, 0, 1, 1)
grid.attach(Gtk.Label(label="Status", xalign=0), 3, 0, 1, 1)
self.status_label = Gtk.Label(label="Idle", xalign=0)
self.status_label.set_selectable(True)
grid.attach(self.status_label, 4, 0, 2, 1)
grid.attach(Gtk.Label(label="Output", xalign=0), 0, 1, 1, 1)
self.output_button = Gtk.FileChooserButton(
@@ -186,55 +202,110 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
)
self.output_button.set_filename(os.path.expanduser("~"))
self.output_button.connect("file-set", self._on_settings_changed)
grid.attach(self.output_button, 1, 1, 3, 1)
grid.attach(self.output_button, 1, 1, 2, 1)
grid.attach(Gtk.Label(label="Logger", xalign=0), 3, 1, 1, 1)
self.logger_combo = Gtk.ComboBoxText()
for logger_name in sorted(result.getLoggers()):
self.logger_combo.append(logger_name, logger_name)
self.logger_combo.set_active_id("whipper")
self.logger_combo.connect("changed", self._on_settings_changed)
grid.attach(self.logger_combo, 4, 1, 2, 1)
grid.attach(Gtk.Label(label="Cover Art", xalign=0), 0, 2, 1, 1)
self.cover_art_combo = Gtk.ComboBoxText()
self.cover_art_combo.append("", "Disabled")
self.cover_art_combo.append("file", "Save file")
self.cover_art_combo.append("embed", "Embed only")
self.cover_art_combo.append("complete", "File + embed")
self.cover_art_combo.set_active(0)
self.cover_art_combo.connect("changed", self._on_settings_changed)
grid.attach(self.cover_art_combo, 1, 2, 1, 1)
grid.attach(Gtk.Label(label="Offset", xalign=0), 2, 2, 1, 1)
self.offset_spin = Gtk.SpinButton.new_with_range(-5000, 5000, 1)
self.offset_spin.set_value(0)
self.offset_spin.connect("value-changed", self._on_settings_changed)
grid.attach(self.offset_spin, 3, 2, 1, 1)
grid.attach(Gtk.Label(label="Retries", xalign=0), 4, 2, 1, 1)
self.max_retries_spin = Gtk.SpinButton.new_with_range(0, 20, 1)
self.max_retries_spin.set_value(5)
self.max_retries_spin.connect("value-changed", self._on_settings_changed)
grid.attach(self.max_retries_spin, 5, 2, 1, 1)
grid.attach(Gtk.Label(label="Working Dir", xalign=0), 0, 2, 1, 1)
self.working_directory_entry = Gtk.Entry()
self.working_directory_entry.set_placeholder_text("Optional working directory")
self.working_directory_entry.connect("changed", self._on_settings_changed)
grid.attach(self.working_directory_entry, 1, 2, 3, 1)
grid.attach(self.working_directory_entry, 1, 3, 2, 1)
grid.attach(Gtk.Label(label="Country", xalign=0), 0, 3, 1, 1)
grid.attach(Gtk.Label(label="Country", xalign=0), 0, 4, 1, 1)
self.country_entry = Gtk.Entry()
self.country_entry.set_placeholder_text("Optional MusicBrainz country filter")
self.country_entry.connect("changed", self._on_settings_changed)
grid.attach(self.country_entry, 1, 3, 1, 1)
grid.attach(self.country_entry, 1, 4, 1, 1)
grid.attach(Gtk.Label(label="Release ID", xalign=0), 2, 3, 1, 1)
grid.attach(Gtk.Label(label="Release ID", xalign=0), 2, 4, 1, 1)
self.release_id_entry = Gtk.Entry()
self.release_id_entry.set_placeholder_text("Optional release override")
self.release_id_entry.connect("changed", self._on_settings_changed)
grid.attach(self.release_id_entry, 3, 3, 1, 1)
grid.attach(self.release_id_entry, 3, 4, 3, 1)
return grid
def _build_actions(self):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.read_button = Gtk.Button(label="Read Disc")
self.read_button = Gtk.Button(label="Detect TOC")
self.read_button.connect("clicked", self._on_read_clicked)
self.read_button.set_tooltip_text("Read TOC and fetch matching MusicBrainz releases")
box.pack_start(self.read_button, False, False, 0)
self.rip_button = Gtk.Button(label="Rip Selected Release")
self.rip_button = Gtk.Button(label="Secure Rip")
self.rip_button.connect("clicked", self._on_rip_clicked)
self.rip_button.set_tooltip_text("Rip the current disc using the selected release metadata")
self.rip_button.set_sensitive(False)
box.pack_start(self.rip_button, False, False, 0)
self.stop_button = Gtk.Button(label="Stop")
self.stop_button = Gtk.Button(label="Abort")
self.stop_button.connect("clicked", self._on_stop_clicked)
self.stop_button.set_tooltip_text("Cancel the current scan or rip")
self.stop_button.set_sensitive(False)
box.pack_start(self.stop_button, False, False, 0)
self.status_label = Gtk.Label(label="Idle", xalign=0)
box.pack_start(self.status_label, True, True, 0)
box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL), False, False, 6)
self.unknown_check = Gtk.CheckButton(label="Unknown")
self.unknown_check.set_active(True)
self.unknown_check.connect("toggled", self._on_unknown_toggled)
box.pack_start(self.unknown_check, False, False, 0)
self.cdr_check = Gtk.CheckButton(label="CD-R")
self.cdr_check.connect("toggled", self._on_settings_changed)
box.pack_start(self.cdr_check, False, False, 0)
self.keep_going_check = Gtk.CheckButton(label="Keep Going")
self.keep_going_check.set_active(True)
self.keep_going_check.connect("toggled", self._on_settings_changed)
box.pack_start(self.keep_going_check, False, False, 0)
self.overread_check = Gtk.CheckButton(label="Overread")
self.overread_check.connect("toggled", self._on_settings_changed)
box.pack_start(self.overread_check, False, False, 0)
box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL), False, False, 6)
self.compact_mode_label = Gtk.Label(
label="Compact EAC-style flow: Detect TOC -> pick release -> Secure Rip",
xalign=0,
)
box.pack_start(self.compact_mode_label, True, True, 0)
return box
def _build_progress(self):
frame = Gtk.Frame(label="Progress")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=10)
frame = Gtk.Frame(label="Extraction Progress")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4, margin=6)
frame.add(box)
self.progress_label = Gtk.Label(label="No task running", xalign=0)
@@ -251,18 +322,27 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
return frame
def _build_main_content(self):
pane = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL)
pane = Gtk.Paned.new(Gtk.Orientation.VERTICAL)
pane.set_wide_handle(True)
pane.pack1(self._build_left_panel(), resize=True, shrink=False)
pane.pack2(self._build_right_panel(), resize=True, shrink=False)
pane.pack1(self._build_workspace(), resize=True, shrink=False)
pane.pack2(self._build_log(), resize=False, shrink=False)
pane.set_position(560)
self.main_pane = pane
return pane
def _build_workspace(self):
pane = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL)
pane.set_wide_handle(True)
pane.pack1(self._build_left_panel(), resize=False, shrink=False)
pane.pack2(self._build_right_panel(), resize=True, shrink=False)
pane.set_position(360)
return pane
def _build_left_panel(self):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
info_frame = Gtk.Frame(label="Disc")
info_grid = Gtk.Grid(column_spacing=10, row_spacing=6, margin=10)
info_grid = Gtk.Grid(column_spacing=8, row_spacing=4, margin=6)
info_frame.add(info_grid)
rows = [
@@ -281,17 +361,23 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
box.pack_start(info_frame, False, False, 0)
release_frame = Gtk.Frame(label="Matching Releases")
release_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=8)
release_frame = Gtk.Frame(label="Metadata / Releases")
release_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=6)
release_frame.add(release_box)
self.release_store = Gtk.ListStore(str, str, str, str, str, object)
self.release_view = Gtk.TreeView(model=self.release_store)
self.release_view.set_headers_clickable(False)
self.release_view.set_enable_search(False)
self.release_view.set_fixed_height_mode(True)
self.release_view.set_grid_lines(Gtk.TreeViewGridLines.BOTH)
self.release_view.get_selection().connect("changed", self._on_release_selected)
for index, title in enumerate(["Artist", "Title", "Year", "Type", "Country"]):
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(title, renderer, text=index)
column.set_resizable(True)
if title in {"Artist", "Title"}:
column.set_expand(True)
self.release_view.append_column(column)
scroll = Gtk.ScrolledWindow()
@@ -304,7 +390,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
details_scroll = Gtk.ScrolledWindow()
details_scroll.set_hexpand(True)
details_scroll.set_vexpand(False)
details_scroll.set_min_content_height(120)
details_scroll.set_min_content_height(90)
details_frame.add(details_scroll)
self.release_details = Gtk.Label(label="Select a release to inspect it.", xalign=0, yalign=0)
@@ -314,21 +400,40 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
release_box.pack_start(details_frame, False, False, 0)
box.pack_start(release_frame, True, True, 0)
box.set_size_request(420, -1)
return box
def _build_right_panel(self):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
tracks_frame = Gtk.Frame(label="Tracks")
tracks_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=8)
tracks_frame = Gtk.Frame(label="Track Extraction Matrix")
tracks_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=6)
tracks_frame.add(tracks_box)
self.track_store = Gtk.ListStore(str, str, str, str)
self.track_columns = {
"number": 0,
"status": 1,
"artist": 2,
"title": 3,
"length": 4,
"test_crc": 5,
"copy_crc": 6,
"accuraterip": 7,
}
self.track_store = Gtk.ListStore(str, str, str, str, str, str, str, str)
self.track_view = Gtk.TreeView(model=self.track_store)
for index, title in enumerate(["#", "Artist", "Title", "Length"]):
self.track_view.set_headers_clickable(False)
self.track_view.set_enable_search(False)
self.track_view.set_fixed_height_mode(True)
self.track_view.set_grid_lines(Gtk.TreeViewGridLines.BOTH)
for index, title in enumerate(["#", "Status", "Artist", "Title", "Length", "Test CRC", "Copy CRC", "AR"]):
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(title, renderer, text=index)
column.set_resizable(True)
if title in {"Artist", "Title"}:
column.set_expand(True)
if title == "Status":
column.set_min_width(140)
self.track_view.append_column(column)
track_scroll = Gtk.ScrolledWindow()
@@ -337,89 +442,42 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
track_scroll.add(self.track_view)
tracks_box.pack_start(track_scroll, True, True, 0)
tracks_box.pack_start(self._build_rip_options(), False, False, 0)
box.pack_start(tracks_frame, True, True, 0)
box.pack_start(self._build_rip_options(), False, False, 0)
return box
def _build_rip_options(self):
frame = Gtk.Frame(label="Rip Options")
grid = Gtk.Grid(column_spacing=10, row_spacing=8, margin=10)
frame = Gtk.Frame(label="Extraction Setup")
grid = Gtk.Grid(column_spacing=8, row_spacing=4, margin=6)
frame.add(grid)
self.unknown_check = Gtk.CheckButton(label="Allow ripping without metadata")
self.unknown_check.set_active(True)
self.unknown_check.connect("toggled", self._on_unknown_toggled)
grid.attach(self.unknown_check, 0, 0, 2, 1)
self.cdr_check = Gtk.CheckButton(label="Allow CD-R")
self.cdr_check.connect("toggled", self._on_settings_changed)
grid.attach(self.cdr_check, 2, 0, 1, 1)
self.keep_going_check = Gtk.CheckButton(label="Keep going on failed tracks")
self.keep_going_check.set_active(True)
self.keep_going_check.connect("toggled", self._on_settings_changed)
grid.attach(self.keep_going_check, 0, 1, 2, 1)
self.overread_check = Gtk.CheckButton(label="Force overread")
self.overread_check.connect("toggled", self._on_settings_changed)
grid.attach(self.overread_check, 2, 1, 1, 1)
grid.attach(Gtk.Label(label="Cover Art", xalign=0), 0, 2, 1, 1)
self.cover_art_combo = Gtk.ComboBoxText()
self.cover_art_combo.append("", "Disabled")
self.cover_art_combo.append("file", "Save file")
self.cover_art_combo.append("embed", "Embed only")
self.cover_art_combo.append("complete", "File + embed")
self.cover_art_combo.set_active(0)
self.cover_art_combo.connect("changed", self._on_settings_changed)
grid.attach(self.cover_art_combo, 1, 2, 1, 1)
grid.attach(Gtk.Label(label="Max Retries", xalign=0), 2, 2, 1, 1)
self.max_retries_spin = Gtk.SpinButton.new_with_range(0, 20, 1)
self.max_retries_spin.set_value(5)
self.max_retries_spin.connect("value-changed", self._on_settings_changed)
grid.attach(self.max_retries_spin, 3, 2, 1, 1)
grid.attach(Gtk.Label(label="Offset", xalign=0), 0, 3, 1, 1)
self.offset_spin = Gtk.SpinButton.new_with_range(-5000, 5000, 1)
self.offset_spin.set_value(0)
self.offset_spin.connect("value-changed", self._on_settings_changed)
grid.attach(self.offset_spin, 1, 3, 1, 1)
grid.attach(Gtk.Label(label="Logger", xalign=0), 2, 3, 1, 1)
self.logger_combo = Gtk.ComboBoxText()
for logger_name in sorted(result.getLoggers()):
self.logger_combo.append(logger_name, logger_name)
self.logger_combo.set_active_id("whipper")
self.logger_combo.connect("changed", self._on_settings_changed)
grid.attach(self.logger_combo, 3, 3, 1, 1)
grid.attach(Gtk.Label(label="Track Tpl", xalign=0), 0, 4, 1, 1)
grid.attach(Gtk.Label(label="Track Tpl", xalign=0), 0, 0, 1, 1)
self.track_template_entry = Gtk.Entry()
self.track_template_entry.set_text(cd_command.DEFAULT_TRACK_TEMPLATE)
self.track_template_entry.connect("changed", self._on_settings_changed)
grid.attach(self.track_template_entry, 1, 4, 3, 1)
grid.attach(self.track_template_entry, 1, 0, 3, 1)
grid.attach(Gtk.Label(label="Disc Tpl", xalign=0), 0, 5, 1, 1)
grid.attach(Gtk.Label(label="Disc Tpl", xalign=0), 0, 1, 1, 1)
self.disc_template_entry = Gtk.Entry()
self.disc_template_entry.set_text(cd_command.DEFAULT_DISC_TEMPLATE)
self.disc_template_entry.connect("changed", self._on_settings_changed)
grid.attach(self.disc_template_entry, 1, 5, 3, 1)
grid.attach(self.disc_template_entry, 1, 1, 3, 1)
note = Gtk.Label(
label="The configured drive offset is loaded automatically when whipper knows this drive.",
label="Advanced templates stay here; fast extraction switches moved to the control center to reduce vertical space.",
xalign=0,
)
note.set_line_wrap(True)
grid.attach(note, 0, 6, 4, 1)
grid.attach(note, 0, 2, 4, 1)
return frame
def _build_log(self):
frame = Gtk.Frame(label="Log")
frame = Gtk.Frame(label="Extraction Log")
scroll = Gtk.ScrolledWindow()
scroll.set_hexpand(True)
scroll.set_vexpand(True)
scroll.set_vexpand(False)
scroll.set_min_content_height(150)
frame.add(scroll)
text_view = Gtk.TextView()
@@ -434,6 +492,14 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
end_iter = self.log_buffer.get_end_iter()
self.log_buffer.insert(end_iter, text)
def _log_action(self, message, *args, level=logging.INFO):
if args:
message = message % args
logger.log(level, message)
if not message.endswith("\n"):
message += "\n"
self._append_log(message)
def _clear_log(self):
self.log_buffer.set_text("")
@@ -563,6 +629,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
current = self.device_combo.get_active_text()
self.device_combo.remove_all()
devices = drive.getAllDevicePaths()
logger.info("refreshing optical drives")
for path in devices:
self.device_combo.append_text(path)
if not devices:
@@ -570,12 +637,15 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
self.device_combo.set_active(0)
self.read_button.set_sensitive(False)
self.rip_button.set_sensitive(False)
logger.warning("no optical drives detected")
else:
self.read_button.set_sensitive(True)
if current in devices:
self.device_combo.set_active(devices.index(current))
else:
self.device_combo.set_active(0)
logger.info("detected %d optical drive(s); active device: %s",
len(devices), self.device_combo.get_active_text())
self._apply_configured_offset()
def _selected_device(self):
@@ -609,11 +679,24 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
for index, track in enumerate(metadata.tracks, start=1):
self.track_store.append([
str(index),
"Ready",
track.artist or "",
track.title or "",
_format_duration_ms(track.duration),
"",
"",
"",
])
def _set_track_field(self, track_number, field, value):
if track_number <= 0 or self.track_store is None or self.track_columns is None:
return False
row_index = track_number - 1
if row_index < 0 or row_index >= len(self.track_store):
return False
self.track_store[row_index][self.track_columns[field]] = value
return False
def _update_release_details(self, metadata):
if metadata is None:
self.release_details.set_text("Select a release to inspect it.")
@@ -646,31 +729,36 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
return
info = drive.getDeviceInfo(device)
if not info:
logger.info("no configured offset information for %s", device)
return
try:
offset = config.Config().getReadOffset(*info)
except KeyError:
logger.info("configured read offset not found for %s", device)
return
if offset is not None:
self.offset_spin.set_value(int(offset))
logger.info("loaded configured read offset %d for %s", offset, device)
def _read_disc_worker(self, device, country):
try:
self.scan_cancel_requested = False
GLib.idle_add(self._clear_log)
GLib.idle_add(self._reset_progress)
GLib.idle_add(self._append_log, "Reading disc from %s\n\n" % device)
GLib.idle_add(self._log_action, "Reading disc from %s", device)
GLib.idle_add(self._set_running_state, True, "Reading disc")
GLib.idle_add(self.progress_label.set_text, "Scanning disc and looking up MusicBrainz")
runner = CancellableSyncRunner()
self.scan_runner = runner
GLib.idle_add(self._log_action, "Preparing drive %s", device)
utils.load_device(device)
utils.unmount_device(device)
if drive.get_cdrom_drive_status(device) == 1:
raise OSError("No CD detected, please insert one and retry")
GLib.idle_add(self._log_action, "Reading disc TOC")
toc_task = cdrdao.ReadTOCTask(device, fast_toc=True)
runner.run(toc_task, verbose=False)
ittoc = toc_task.toc.table
@@ -681,6 +769,12 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
if self.scan_cancel_requested:
raise RuntimeError("Disc scan cancelled")
GLib.idle_add(
self._log_action,
"Querying MusicBrainz for %s%s",
mbid,
" (country=%s)" % country if country else "",
)
try:
releases = mbngs.musicbrainz(mbid, country=country or None, record=False)
except mbngs.NotFoundException:
@@ -707,12 +801,12 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
self._append_log("CDDB disc id: %s\n" % cddb)
self._append_log("MusicBrainz disc id: %s\n" % mbid)
self._append_log("Disc duration: %s, %s audio tracks\n" % (duration, track_count))
self._append_log("\nFound %d matching release(s)\n" % len(releases))
self._log_action("Found %d matching release(s)", len(releases))
self._update_release_store(releases)
self._set_running_state(False, "Disc ready")
self.progress_label.set_text("Disc metadata loaded")
if not releases:
self._append_log("No MusicBrainz matches found\n")
self._log_action("No MusicBrainz matches found", level=logging.WARNING)
return False
GLib.idle_add(apply_results)
@@ -726,12 +820,12 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
self._set_label("device", device)
if self.scan_cancel_requested:
self._set_label("disc_status", "Cancelled")
self._append_log("Disc scan cancelled\n")
self._log_action("Disc scan cancelled", level=logging.WARNING)
self._set_running_state(False, "Cancelled")
self.progress_label.set_text("Disc scan cancelled")
else:
self._set_label("disc_status", "Error")
self._append_log("%s\n" % exc)
self._log_action("%s", exc, level=logging.ERROR)
self._set_running_state(False, "Failed")
self.progress_label.set_text("Disc scan failed")
return False
@@ -813,6 +907,14 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
self.track_bar.set_fraction(value)
self.track_bar.set_text(description)
self.progress_label.set_text("%s: %s" % (item_label, description))
if item_label.startswith("Track "):
try:
track_number = int(item_label.split()[1])
except (IndexError, ValueError):
track_number = None
if track_number is not None:
percent = int(value * 100)
self._set_track_field(track_number, "status", "%s %d%%" % (description, percent))
return False
def _mark_track_finished(self, item_label, item_index, item_total, skipped=False):
@@ -823,6 +925,13 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
self.track_bar.set_fraction(1.0)
self.track_bar.set_text("%s %s" % (item_label, "skipped" if skipped else "done"))
self.progress_label.set_text("%s %s" % (item_label, "failed" if skipped else "verified"))
if item_label.startswith("Track "):
try:
track_number = int(item_label.split()[1])
except (IndexError, ValueError):
track_number = None
if track_number is not None:
self._set_track_field(track_number, "status", "Skipped" if skipped else "Verified")
return False
def _finish_rip(self, returncode, status="Done"):
@@ -868,6 +977,8 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
if os.path.exists(path):
GLib.idle_add(self._append_log, "%s already exists, verifying\n" % item_label)
if track_number > 0:
GLib.idle_add(self._set_track_field, track_number, "status", "Verifying existing")
if not program.verifyTrack(runner, track_result):
GLib.idle_add(self._append_log, "%s verification failed, reripping\n" % item_label)
os.unlink(path)
@@ -881,6 +992,13 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
raise RipCancelledError("Rip cancelled")
extra = "" if tries == 1 else " (try %d)" % tries
GLib.idle_add(self._append_log, "%s%s: %s\n" % (item_label, extra, os.path.basename(path)))
if track_number > 0:
GLib.idle_add(
self._set_track_field,
track_number,
"status",
"Test & Copy%s" % extra,
)
tag_list = program.getTagList(track_number, mbdiscid)
if track_number > 0 and itable.tracks[track_number - 1].isrc is not None:
@@ -917,6 +1035,19 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
track_result.copyduration += rip_task.copyduration
if track_result.filename != rip_task.path:
track_result.filename = rip_task.path
if track_number > 0:
GLib.idle_add(
self._set_track_field,
track_number,
"test_crc",
"%08X" % track_result.testcrc if track_result.testcrc is not None else "",
)
GLib.idle_add(
self._set_track_field,
track_number,
"copy_crc",
"%08X" % track_result.copycrc if track_result.copycrc is not None else "",
)
break
except Exception as exc:
if self.rip_cancel_requested:
@@ -930,6 +1061,8 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
if settings["keep_going"]:
track_result.skipped = True
skipped_tracks.append(track_result)
if track_number > 0:
GLib.idle_add(self._set_track_field, track_number, "accuraterip", "Skipped")
GLib.idle_add(self._mark_track_finished, item_label, item_index, item_total, True)
return
raise RuntimeError("%s can't be ripped" % item_label)
@@ -939,6 +1072,8 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
return
if track_result.testcrc != track_result.copycrc:
if track_number > 0:
GLib.idle_add(self._set_track_field, track_number, "accuraterip", "CRC mismatch")
raise RuntimeError("CRCs did not match for %s" % item_label)
if track_number == 0:
@@ -962,6 +1097,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
program.result.table.getTrackLength(track_number),
track_number,
)
GLib.idle_add(self._set_track_field, track_number, "accuraterip", "Pending AR")
GLib.idle_add(self._mark_track_finished, item_label, item_index, item_total, False)
@@ -974,21 +1110,40 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
GLib.idle_add(self._reset_progress)
GLib.idle_add(self._set_running_state, True, "Ripping disc")
GLib.idle_add(self.progress_label.set_text, "Preparing rip")
GLib.idle_add(self._append_log, "Starting native whipper rip\n\n")
GLib.idle_add(self._log_action, "Starting native whipper rip")
runner = CancellableSyncRunner()
self.rip_runner = runner
conf = config.Config()
program = Program(conf, record=False)
GLib.idle_add(
self._log_action,
"Rip settings: device=%s output=%s working_dir=%s logger=%s offset=%d overread=%s cover_art=%s retries=%s keep_going=%s cdr=%s unknown=%s",
settings["device"],
settings["output_directory"],
settings["working_directory"] or "-",
settings["logger"],
settings["offset"],
settings["overread"],
settings["cover_art"] or "disabled",
settings["max_retries"],
settings["keep_going"],
settings["cdr"],
settings["unknown"],
)
if settings["working_directory"]:
GLib.idle_add(self._log_action, "Changing working directory to %s", settings["working_directory"])
os.chdir(os.path.expanduser(settings["working_directory"]))
GLib.idle_add(self._log_action, "Preparing drive %s", settings["device"])
utils.load_device(settings["device"])
utils.unmount_device(settings["device"])
if drive.get_cdrom_drive_status(settings["device"]) == 1:
raise OSError("No CD detected, please insert one and retry")
GLib.idle_add(self._log_action, "Reading fast TOC")
ittoc = program.getFastToc(runner, settings["device"])
program.getRipResult()
cddb = ittoc.getCDDBDiscId()
@@ -996,22 +1151,27 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
GLib.idle_add(self._append_log, "CDDB disc id: %s\n" % cddb)
GLib.idle_add(self._append_log, "MusicBrainz disc id: %s\n" % mbdiscid)
GLib.idle_add(self._log_action, "Resolving release metadata")
program.metadata = self._resolve_release_metadata(mbdiscid, settings)
if program.metadata is None and not settings["unknown"]:
raise RuntimeError("Unable to resolve disc metadata. Select a release or enable ripping without metadata.")
if program.metadata is not None:
program.metadata.discid = mbdiscid
GLib.idle_add(
self._append_log,
self._log_action,
"Using release: %s - %s\n" % (
program.metadata.artist or "Unknown Artist",
program.metadata.releaseTitle or program.metadata.title or "Unknown Title",
),
)
else:
GLib.idle_add(self._log_action, "Continuing without release metadata", level=logging.WARNING)
program.result.isCdr = cdrdao.DetectCdr(settings["device"])
if program.result.isCdr and not settings["cdr"]:
raise RuntimeError("Inserted disc appears to be a CD-R. Enable 'Allow CD-R' to continue.")
if program.result.isCdr:
GLib.idle_add(self._log_action, "Detected CD-R media", level=logging.WARNING)
out_fpath = program.getPath(
settings["output_directory"],
@@ -1019,6 +1179,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
mbdiscid,
program.metadata,
)
GLib.idle_add(self._log_action, "Reading full disc table")
itable = program.getTable(
runner,
ittoc.getCDDBDiscId(),
@@ -1045,6 +1206,15 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
program.result.overread = settings["overread"]
program.result.logger = settings["logger"]
program.outdir = settings["output_directory"]
GLib.idle_add(
self._log_action,
"Drive info: vendor=%s model=%s release=%s offset=%s overread=%s",
program.result.vendor,
program.result.model,
program.result.release,
program.result.offset,
program.result.overread,
)
disc_name = program.getPath(program.outdir, settings["disc_template"], mbdiscid, program.metadata)
dirname = os.path.dirname(disc_name)
@@ -1052,17 +1222,22 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
log_file = disc_name + ".log"
if os.path.exists(log_file):
raise RuntimeError("output directory %s is a finished rip" % dirname)
GLib.idle_add(self._log_action, "Using existing output directory %s", dirname)
else:
os.makedirs(dirname)
GLib.idle_add(self._log_action, "Created output directory %s", dirname)
cover_art_path = None
if settings["cover_art"] in {"embed", "complete"} and importlib.util.find_spec("PIL") is None:
GLib.idle_add(self._append_log, "Cover art embedding requires Pillow; continuing without embedded art\n")
GLib.idle_add(self._log_action, "Cover art embedding requires Pillow; continuing without embedded art", level=logging.WARNING)
elif settings["cover_art"] in {"file", "embed", "complete"}:
if getattr(program.metadata, "mbid", None):
GLib.idle_add(self._log_action, "Fetching cover art with mode %s", settings["cover_art"])
cover_art_path = program.getCoverArt(dirname, program.metadata.mbid)
if cover_art_path is not None:
GLib.idle_add(self._log_action, "Cover art saved to %s", cover_art_path)
else:
GLib.idle_add(self._append_log, "Cover art requested but disc metadata is unavailable\n")
GLib.idle_add(self._log_action, "Cover art requested but disc metadata is unavailable", level=logging.WARNING)
if settings["cover_art"] == "file":
embed_cover_art_path = None
else:
@@ -1071,14 +1246,16 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
skipped_tracks = []
rip_numbers = []
if program.getHTOA():
GLib.idle_add(self._log_action, "Hidden Track One Audio detected")
rip_numbers.append(0)
for index, track in enumerate(itable.tracks, start=1):
if track.audio:
rip_numbers.append(index)
else:
GLib.idle_add(self._append_log, "Skipping data track %d, not implemented\n" % index)
GLib.idle_add(self._log_action, "Skipping data track %d, not implemented", index, level=logging.WARNING)
track.indexes[1].relative = 0
GLib.idle_add(self._log_action, "Planned rip items: %d", len(rip_numbers))
total_items = len(rip_numbers)
for item_index, track_number in enumerate(rip_numbers, start=1):
if self.rip_cancel_requested:
@@ -1098,15 +1275,19 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
if settings["cover_art"] == "embed" and cover_art_path is not None:
os.remove(cover_art_path)
GLib.idle_add(self._log_action, "Removed temporary cover art file %s", cover_art_path)
if self.rip_cancel_requested:
raise RipCancelledError("Rip cancelled")
program.skipped_tracks = skipped_tracks or None
GLib.idle_add(self._log_action, "Writing CUE sheet")
program.writeCue(disc_name)
GLib.idle_add(self._log_action, "Writing M3U playlist")
program.write_m3u(disc_name)
try:
GLib.idle_add(self._log_action, "Running AccurateRip image verification")
with contextlib.redirect_stdout(io.StringIO()) as stdout_buffer:
program.verifyImage(runner, itable)
accurip.print_report(program.result)
@@ -1114,19 +1295,20 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
if report_output:
GLib.idle_add(self._append_log, "\n" + report_output + "\n")
except accurip.EntryNotFound:
GLib.idle_add(self._append_log, "AccurateRip entry not found\n")
GLib.idle_add(self._log_action, "AccurateRip entry not found", level=logging.WARNING)
txt_logger = result.getLoggers()[settings["logger"]]()
GLib.idle_add(self._log_action, "Writing rip log with logger '%s'", settings["logger"])
program.writeLog(disc_name, txt_logger)
if skipped_tracks:
GLib.idle_add(self._append_log, "%d track(s) were skipped during this rip\n" % len(skipped_tracks))
GLib.idle_add(self._log_action, "%d track(s) were skipped during this rip", len(skipped_tracks), level=logging.WARNING)
GLib.idle_add(self._finish_rip, 5, "Done with skipped tracks")
else:
GLib.idle_add(self._append_log, "Rip finished successfully\n")
GLib.idle_add(self._log_action, "Rip finished successfully")
GLib.idle_add(self._finish_rip, 0, "Done")
except Exception as exc:
if not isinstance(exc, RipCancelledError):
GLib.idle_add(self._append_log, "%s\n" % exc)
GLib.idle_add(self._log_action, "%s", exc, level=logging.ERROR)
GLib.idle_add(self._finish_rip, 1, "Cancelled" if self.rip_cancel_requested else "Failed")
finally:
self.rip_runner = None
@@ -1146,6 +1328,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
self.rip_button.set_sensitive(self._can_rip())
def _on_refresh_clicked(self, _button):
self._log_action("Refreshing drive list")
self._refresh_devices()
def _on_unknown_toggled(self, _button):
@@ -1167,12 +1350,21 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
def _on_device_changed(self, _combo):
self._apply_configured_offset()
self._save_gui_settings()
device = self._selected_device()
if device:
self._log_action("Selected device %s", device)
def _on_read_clicked(self, _button):
device = self._selected_device()
if not device:
self._log_action("Read requested without an active device", level=logging.WARNING)
return
country = self.country_entry.get_text().strip()
self._log_action(
"Read requested for %s%s",
device,
" with country filter %s" % country if country else "",
)
self.worker = threading.Thread(
target=self._read_disc_worker,
args=(device, country),
@@ -1182,16 +1374,24 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
def _on_rip_clicked(self, _button):
if not self._can_rip():
self._append_log("Select a release or enable ripping without metadata.\n")
self._log_action("Rip requested without a selected release", level=logging.WARNING)
self.status_label.set_text("Missing release")
return
settings = self._collect_rip_settings()
try:
self._validate_rip_settings(settings)
except Exception as exc:
self._append_log("%s\n" % exc)
self._log_action("%s", exc, level=logging.ERROR)
self.status_label.set_text("Invalid settings")
return
if self.current_release is not None:
self._log_action(
"Rip requested for release %s - %s",
self.current_release.artist or "Unknown Artist",
self.current_release.releaseTitle or self.current_release.title or "Unknown Title",
)
else:
self._log_action("Rip requested without bound metadata release", level=logging.WARNING)
self.worker = threading.Thread(
target=self._rip_disc_worker,
args=(settings,),
@@ -1202,11 +1402,11 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
def _on_stop_clicked(self, _button):
if self.scan_runner is not None:
self.scan_cancel_requested = True
self._append_log("\nStopping scan...\n")
self._log_action("Stopping scan", level=logging.WARNING)
self.scan_runner.cancel()
elif self.rip_runner is not None:
self.rip_cancel_requested = True
self._append_log("\nStopping rip...\n")
self._log_action("Stopping rip", level=logging.WARNING)
self.rip_runner.cancel()

View File

@@ -277,6 +277,16 @@ def _make_ui_app(tmp_path):
app.track_bar = FakeProgressBar()
app.release_store = FakeListStore()
app.track_store = FakeListStore()
app.track_columns = {
"number": 0,
"status": 1,
"artist": 2,
"title": 3,
"length": 4,
"test_crc": 5,
"copy_crc": 6,
"accuraterip": 7,
}
app.release_view = FakeReleaseView(app.release_store)
app.release_details = FakeLabel()
app.info_labels = {key: FakeLabel() for key in [
@@ -296,6 +306,7 @@ def _make_ui_app(tmp_path):
app._config_path = lambda: tmp_path / "gui.json"
_bind_methods(
app,
"_log_action",
"_collect_gui_settings",
"_save_gui_settings",
"_load_gui_settings",
@@ -304,6 +315,7 @@ def _make_ui_app(tmp_path):
"_set_running_state",
"_update_release_store",
"_update_track_store",
"_set_track_field",
"_update_release_details",
"_update_rip_task_progress",
"_finish_rip",
@@ -389,7 +401,8 @@ def test_release_selection_and_progress_updates(monkeypatch, tmp_path):
gui.WhipperGui._finish_rip(app, 0, "Done")
assert app.current_release is metadata
assert app.track_store[0][1] == "Track Artist"
assert app.track_store[0][2] == "Track Artist"
assert app.track_store[0][1].startswith("Encoding")
assert app.release_details.get_text().startswith("Artist: Artist")
assert app.overall_bar.fraction == 1.0
assert app.track_bar.fraction == 1.0