Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ae1a42d90 | |||
| 63aee123e7 | |||
| 1999ce3657 | |||
| f78a130926 | |||
| 5baadd9dbb | |||
| 997bfef893 | |||
| 290d650f80 |
593
whipper/gui.py
593
whipper/gui.py
@@ -6,6 +6,7 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
import contextlib
|
import contextlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_GUI_IMPORT_ERROR = None
|
_GUI_IMPORT_ERROR = None
|
||||||
@@ -30,12 +31,13 @@ except (ImportError, ValueError) as exc:
|
|||||||
|
|
||||||
from whipper.common import accurip, common, config, drive, mbngs, task as whipper_task
|
from whipper.common import accurip, common, config, drive, mbngs, task as whipper_task
|
||||||
from whipper.common.program import Program
|
from whipper.common.program import Program
|
||||||
from whipper.command import cd as cd_command
|
from whipper.command import cd as cd_command, offset as offset_command
|
||||||
from whipper.program import cdrdao, cdparanoia, utils
|
from whipper.program import arc, cdrdao, cdparanoia, utils
|
||||||
from whipper.result import result
|
from whipper.result import result
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
APP_ID = "com.github.whipper_team.Whipper"
|
||||||
|
|
||||||
|
|
||||||
class RipCancelledError(Exception):
|
class RipCancelledError(Exception):
|
||||||
@@ -62,6 +64,30 @@ def require_gui_runtime():
|
|||||||
raise RuntimeError(gui_runtime_error_message()) from _GUI_IMPORT_ERROR
|
raise RuntimeError(gui_runtime_error_message()) from _GUI_IMPORT_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_icon_candidates():
|
||||||
|
local_icon = Path(__file__).resolve().parent.parent / "data" / ("%s.svg" % APP_ID)
|
||||||
|
yield local_icon
|
||||||
|
|
||||||
|
data_dirs = []
|
||||||
|
xdg_data_home = os.environ.get("XDG_DATA_HOME")
|
||||||
|
if xdg_data_home:
|
||||||
|
data_dirs.append(Path(xdg_data_home))
|
||||||
|
data_dirs.extend(Path(path) for path in os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":") if path)
|
||||||
|
|
||||||
|
for base in data_dirs:
|
||||||
|
yield base / "icons" / "hicolor" / "scalable" / "apps" / ("%s.svg" % APP_ID)
|
||||||
|
yield base / "icons" / "hicolor" / "256x256" / "apps" / ("%s.png" % APP_ID)
|
||||||
|
yield base / "pixmaps" / ("%s.png" % APP_ID)
|
||||||
|
yield base / "pixmaps" / ("%s.svg" % APP_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_icon_path():
|
||||||
|
for candidate in _iter_icon_candidates():
|
||||||
|
if candidate.is_file():
|
||||||
|
return str(candidate)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _format_duration_ms(duration_ms):
|
def _format_duration_ms(duration_ms):
|
||||||
if duration_ms is None:
|
if duration_ms is None:
|
||||||
return ""
|
return ""
|
||||||
@@ -86,7 +112,13 @@ def _release_duration_distance(metadata, duration_ms):
|
|||||||
class WhipperGui(Gtk.Application if Gtk is not None else object):
|
class WhipperGui(Gtk.Application if Gtk is not None else object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
require_gui_runtime()
|
require_gui_runtime()
|
||||||
super().__init__(application_id="com.github.whipper_team.WhipperGui")
|
GLib.set_prgname(APP_ID)
|
||||||
|
super().__init__(application_id=APP_ID)
|
||||||
|
GLib.set_application_name("Whipper")
|
||||||
|
Gtk.Window.set_default_icon_name(APP_ID)
|
||||||
|
icon_path = _resolve_icon_path()
|
||||||
|
if icon_path:
|
||||||
|
Gtk.Window.set_default_icon_from_file(icon_path)
|
||||||
self.window = None
|
self.window = None
|
||||||
self.main_pane = None
|
self.main_pane = None
|
||||||
|
|
||||||
@@ -96,8 +128,10 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.scan_data = None
|
self.scan_data = None
|
||||||
self.scan_runner = None
|
self.scan_runner = None
|
||||||
self.rip_runner = None
|
self.rip_runner = None
|
||||||
|
self.drive_runner = None
|
||||||
self.scan_cancel_requested = False
|
self.scan_cancel_requested = False
|
||||||
self.rip_cancel_requested = False
|
self.rip_cancel_requested = False
|
||||||
|
self.drive_cancel_requested = False
|
||||||
self.current_release = None
|
self.current_release = None
|
||||||
self.current_track_number = 0
|
self.current_track_number = 0
|
||||||
self.current_track_total = 0
|
self.current_track_total = 0
|
||||||
@@ -108,6 +142,8 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.country_entry = None
|
self.country_entry = None
|
||||||
self.release_id_entry = None
|
self.release_id_entry = None
|
||||||
self.refresh_button = None
|
self.refresh_button = None
|
||||||
|
self.analyze_button = None
|
||||||
|
self.find_offset_button = None
|
||||||
self.read_button = None
|
self.read_button = None
|
||||||
self.rip_button = None
|
self.rip_button = None
|
||||||
self.stop_button = None
|
self.stop_button = None
|
||||||
@@ -122,6 +158,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.logger_combo = None
|
self.logger_combo = None
|
||||||
self.track_template_entry = None
|
self.track_template_entry = None
|
||||||
self.disc_template_entry = None
|
self.disc_template_entry = None
|
||||||
|
self.compact_mode_label = None
|
||||||
|
|
||||||
self.release_store = None
|
self.release_store = None
|
||||||
self.release_view = None
|
self.release_view = None
|
||||||
@@ -149,107 +186,45 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
def _build_window(self):
|
def _build_window(self):
|
||||||
window = Gtk.ApplicationWindow(application=self)
|
window = Gtk.ApplicationWindow(application=self)
|
||||||
window.set_title("Whipper")
|
window.set_title("Whipper")
|
||||||
window.set_icon_name("com.github.whipper_team.Whipper")
|
window.set_icon_name(APP_ID)
|
||||||
window.set_default_size(1380, 900)
|
icon_path = _resolve_icon_path()
|
||||||
window.set_border_width(8)
|
if icon_path:
|
||||||
self._install_css()
|
window.set_icon_from_file(icon_path)
|
||||||
|
window.set_wmclass(APP_ID, APP_ID)
|
||||||
|
window.set_default_size(1380, 820)
|
||||||
|
window.set_border_width(6)
|
||||||
|
|
||||||
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
root.get_style_context().add_class("eac-shell")
|
|
||||||
window.add(root)
|
window.add(root)
|
||||||
|
|
||||||
root.pack_start(self._build_transport_strip(), 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_progress(), False, False, 0)
|
||||||
root.pack_start(self._build_main_content(), True, True, 0)
|
root.pack_start(self._build_main_content(), True, True, 0)
|
||||||
root.pack_start(self._build_log(), False, True, 0)
|
|
||||||
|
|
||||||
self._refresh_devices()
|
self._refresh_devices()
|
||||||
self._load_gui_settings()
|
self._load_gui_settings()
|
||||||
window.show_all()
|
window.show_all()
|
||||||
return window
|
return window
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _install_css():
|
|
||||||
provider = Gtk.CssProvider()
|
|
||||||
provider.load_from_data(
|
|
||||||
b"""
|
|
||||||
.eac-shell {
|
|
||||||
background: #d9d9d9;
|
|
||||||
}
|
|
||||||
frame > border {
|
|
||||||
border: 1px solid #8a8a8a;
|
|
||||||
border-radius: 0;
|
|
||||||
background: #ececec;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
frame > label {
|
|
||||||
color: #1f1f1f;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.eac-toolbar {
|
|
||||||
background: linear-gradient(to bottom, #f7f7f7, #d8d8d8);
|
|
||||||
border: 1px solid #8a8a8a;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
.eac-toolbar button {
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: linear-gradient(to bottom, #fefefe, #dbdbdb);
|
|
||||||
border: 1px solid #7e7e7e;
|
|
||||||
color: #111111;
|
|
||||||
}
|
|
||||||
.eac-toolbar button.suggested-action {
|
|
||||||
background: linear-gradient(to bottom, #e5f2ff, #b7d4f3);
|
|
||||||
}
|
|
||||||
.eac-toolbar button.destructive-action {
|
|
||||||
background: linear-gradient(to bottom, #fbe7e7, #e7b7b7);
|
|
||||||
}
|
|
||||||
.eac-matrix,
|
|
||||||
.eac-log {
|
|
||||||
background: #fcfcfc;
|
|
||||||
color: #121212;
|
|
||||||
border: 1px solid #919191;
|
|
||||||
}
|
|
||||||
.eac-log text {
|
|
||||||
background: #fcfcfc;
|
|
||||||
color: #111111;
|
|
||||||
font: 10pt Monospace;
|
|
||||||
}
|
|
||||||
.eac-status {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.eac-subtle {
|
|
||||||
color: #4c4c4c;
|
|
||||||
}
|
|
||||||
progressbar trough {
|
|
||||||
min-height: 16px;
|
|
||||||
border-radius: 0;
|
|
||||||
background: #cfcfcf;
|
|
||||||
border: 1px solid #8e8e8e;
|
|
||||||
}
|
|
||||||
progressbar progress {
|
|
||||||
border-radius: 0;
|
|
||||||
background: linear-gradient(to bottom, #8fc2f3, #4f84b6);
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
Gtk.StyleContext.add_provider_for_screen(
|
|
||||||
Gtk.Window().get_screen(),
|
|
||||||
provider,
|
|
||||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_transport_strip(self):
|
def _build_transport_strip(self):
|
||||||
frame = Gtk.Frame(label="Extraction Control Center")
|
frame = Gtk.Frame(label="Extraction Control Center")
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=8)
|
shell = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, margin=6)
|
||||||
frame.add(box)
|
frame.add(shell)
|
||||||
box.pack_start(self._build_controls(), False, False, 0)
|
|
||||||
box.pack_start(self._build_actions(), False, False, 0)
|
primary = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
primary.pack_start(self._build_controls(), False, False, 0)
|
||||||
|
primary.pack_start(self._build_actions(), False, False, 0)
|
||||||
|
shell.pack_start(primary, True, True, 0)
|
||||||
|
|
||||||
|
shell.pack_start(self._build_rip_options(), False, False, 0)
|
||||||
|
|
||||||
|
secondary = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
secondary.pack_start(self._build_disc_info_frame(), False, False, 0)
|
||||||
|
shell.pack_start(secondary, False, False, 0)
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def _build_controls(self):
|
def _build_controls(self):
|
||||||
grid = Gtk.Grid(column_spacing=8, row_spacing=8)
|
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
|
||||||
|
|
||||||
grid.attach(Gtk.Label(label="Drive", xalign=0), 0, 0, 1, 1)
|
grid.attach(Gtk.Label(label="Drive", xalign=0), 0, 0, 1, 1)
|
||||||
self.device_combo = Gtk.ComboBoxText()
|
self.device_combo = Gtk.ComboBoxText()
|
||||||
@@ -261,10 +236,20 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.refresh_button.set_tooltip_text("Refresh the list of detected optical drives")
|
self.refresh_button.set_tooltip_text("Refresh the list of detected optical drives")
|
||||||
grid.attach(self.refresh_button, 2, 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.analyze_button = Gtk.Button(label="Analyze Drive")
|
||||||
|
self.analyze_button.connect("clicked", self._on_analyze_drive_clicked)
|
||||||
|
self.analyze_button.set_tooltip_text("Probe whether cdparanoia can defeat this drive's audio cache")
|
||||||
|
grid.attach(self.analyze_button, 3, 0, 1, 1)
|
||||||
|
|
||||||
|
self.find_offset_button = Gtk.Button(label="Find Offset")
|
||||||
|
self.find_offset_button.connect("clicked", self._on_find_offset_clicked)
|
||||||
|
self.find_offset_button.set_tooltip_text("Detect the configured read offset using an AccurateRip-known disc")
|
||||||
|
grid.attach(self.find_offset_button, 4, 0, 1, 1)
|
||||||
|
|
||||||
|
grid.attach(Gtk.Label(label="Status", xalign=0), 5, 0, 1, 1)
|
||||||
self.status_label = Gtk.Label(label="Idle", xalign=0)
|
self.status_label = Gtk.Label(label="Idle", xalign=0)
|
||||||
self.status_label.set_selectable(True)
|
self.status_label.set_selectable(True)
|
||||||
grid.attach(self.status_label, 4, 0, 2, 1)
|
grid.attach(self.status_label, 6, 0, 1, 1)
|
||||||
|
|
||||||
grid.attach(Gtk.Label(label="Output", xalign=0), 0, 1, 1, 1)
|
grid.attach(Gtk.Label(label="Output", xalign=0), 0, 1, 1, 1)
|
||||||
self.output_button = Gtk.FileChooserButton(
|
self.output_button = Gtk.FileChooserButton(
|
||||||
@@ -283,29 +268,50 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.logger_combo.connect("changed", self._on_settings_changed)
|
self.logger_combo.connect("changed", self._on_settings_changed)
|
||||||
grid.attach(self.logger_combo, 4, 1, 2, 1)
|
grid.attach(self.logger_combo, 4, 1, 2, 1)
|
||||||
|
|
||||||
grid.attach(Gtk.Label(label="Working Dir", xalign=0), 0, 2, 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="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, 3, 1, 1)
|
||||||
self.working_directory_entry = Gtk.Entry()
|
self.working_directory_entry = Gtk.Entry()
|
||||||
self.working_directory_entry.set_placeholder_text("Optional working directory")
|
self.working_directory_entry.set_placeholder_text("Optional working directory")
|
||||||
self.working_directory_entry.connect("changed", self._on_settings_changed)
|
self.working_directory_entry.connect("changed", self._on_settings_changed)
|
||||||
grid.attach(self.working_directory_entry, 1, 2, 2, 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 = Gtk.Entry()
|
||||||
self.country_entry.set_placeholder_text("Optional MusicBrainz country filter")
|
self.country_entry.set_placeholder_text("Optional MusicBrainz country filter")
|
||||||
self.country_entry.connect("changed", self._on_settings_changed)
|
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 = Gtk.Entry()
|
||||||
self.release_id_entry.set_placeholder_text("Optional release override")
|
self.release_id_entry.set_placeholder_text("Optional release override")
|
||||||
self.release_id_entry.connect("changed", self._on_settings_changed)
|
self.release_id_entry.connect("changed", self._on_settings_changed)
|
||||||
grid.attach(self.release_id_entry, 3, 3, 3, 1)
|
grid.attach(self.release_id_entry, 3, 4, 3, 1)
|
||||||
|
|
||||||
return grid
|
return grid
|
||||||
|
|
||||||
def _build_actions(self):
|
def _build_actions(self):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
box.get_style_context().add_class("eac-toolbar")
|
|
||||||
|
|
||||||
self.read_button = Gtk.Button(label="Detect TOC")
|
self.read_button = Gtk.Button(label="Detect TOC")
|
||||||
self.read_button.connect("clicked", self._on_read_clicked)
|
self.read_button.connect("clicked", self._on_read_clicked)
|
||||||
@@ -316,33 +322,49 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.rip_button.connect("clicked", self._on_rip_clicked)
|
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_tooltip_text("Rip the current disc using the selected release metadata")
|
||||||
self.rip_button.set_sensitive(False)
|
self.rip_button.set_sensitive(False)
|
||||||
self.rip_button.get_style_context().add_class("suggested-action")
|
|
||||||
box.pack_start(self.rip_button, False, False, 0)
|
box.pack_start(self.rip_button, False, False, 0)
|
||||||
|
|
||||||
self.stop_button = Gtk.Button(label="Abort")
|
self.stop_button = Gtk.Button(label="Abort")
|
||||||
self.stop_button.connect("clicked", self._on_stop_clicked)
|
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_tooltip_text("Cancel the current scan or rip")
|
||||||
self.stop_button.set_sensitive(False)
|
self.stop_button.set_sensitive(False)
|
||||||
self.stop_button.get_style_context().add_class("destructive-action")
|
|
||||||
box.pack_start(self.stop_button, False, False, 0)
|
box.pack_start(self.stop_button, False, False, 0)
|
||||||
|
|
||||||
box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL), False, False, 6)
|
box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL), False, False, 6)
|
||||||
|
|
||||||
action_hint = Gtk.Label(
|
self.unknown_check = Gtk.CheckButton(label="Unknown")
|
||||||
label="EAC-style flow: Detect TOC -> pick release -> Secure Rip",
|
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,
|
xalign=0,
|
||||||
)
|
)
|
||||||
action_hint.get_style_context().add_class("eac-subtle")
|
box.pack_start(self.compact_mode_label, True, True, 0)
|
||||||
box.pack_start(action_hint, True, True, 0)
|
|
||||||
return box
|
return box
|
||||||
|
|
||||||
def _build_progress(self):
|
def _build_progress(self):
|
||||||
frame = Gtk.Frame(label="Extraction Progress")
|
frame = Gtk.Frame(label="Extraction Progress")
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=8)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4, margin=6)
|
||||||
frame.add(box)
|
frame.add(box)
|
||||||
|
|
||||||
self.progress_label = Gtk.Label(label="No task running", xalign=0)
|
self.progress_label = Gtk.Label(label="No task running", xalign=0)
|
||||||
self.progress_label.get_style_context().add_class("eac-status")
|
|
||||||
box.pack_start(self.progress_label, False, False, 0)
|
box.pack_start(self.progress_label, False, False, 0)
|
||||||
|
|
||||||
self.overall_bar = Gtk.ProgressBar(show_text=True)
|
self.overall_bar = Gtk.ProgressBar(show_text=True)
|
||||||
@@ -360,6 +382,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
pane.set_wide_handle(True)
|
pane.set_wide_handle(True)
|
||||||
pane.pack1(self._build_workspace(), resize=True, shrink=False)
|
pane.pack1(self._build_workspace(), resize=True, shrink=False)
|
||||||
pane.pack2(self._build_log(), resize=False, shrink=False)
|
pane.pack2(self._build_log(), resize=False, shrink=False)
|
||||||
|
pane.set_position(470)
|
||||||
self.main_pane = pane
|
self.main_pane = pane
|
||||||
return pane
|
return pane
|
||||||
|
|
||||||
@@ -368,17 +391,21 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
pane.set_wide_handle(True)
|
pane.set_wide_handle(True)
|
||||||
pane.pack1(self._build_left_panel(), resize=False, shrink=False)
|
pane.pack1(self._build_left_panel(), resize=False, shrink=False)
|
||||||
pane.pack2(self._build_right_panel(), resize=True, shrink=False)
|
pane.pack2(self._build_right_panel(), resize=True, shrink=False)
|
||||||
|
pane.set_position(330)
|
||||||
return pane
|
return pane
|
||||||
|
|
||||||
def _build_left_panel(self):
|
def _build_disc_info_frame(self):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
|
||||||
|
|
||||||
info_frame = Gtk.Frame(label="Disc")
|
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)
|
info_frame.add(info_grid)
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
("Device", "device"),
|
("Device", "device"),
|
||||||
|
("Vendor", "vendor"),
|
||||||
|
("Model", "model"),
|
||||||
|
("Release", "release"),
|
||||||
|
("Read offset", "read_offset"),
|
||||||
|
("Cache defeat", "cache_defeat"),
|
||||||
("Status", "disc_status"),
|
("Status", "disc_status"),
|
||||||
("CDDB", "cddb"),
|
("CDDB", "cddb"),
|
||||||
("Disc ID", "mbid"),
|
("Disc ID", "mbid"),
|
||||||
@@ -390,20 +417,20 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
value = Gtk.Label(label="—", xalign=0, selectable=True)
|
value = Gtk.Label(label="—", xalign=0, selectable=True)
|
||||||
info_grid.attach(value, 1, row, 1, 1)
|
info_grid.attach(value, 1, row, 1, 1)
|
||||||
self.info_labels[key] = value
|
self.info_labels[key] = value
|
||||||
|
return info_frame
|
||||||
|
|
||||||
box.pack_start(info_frame, False, False, 0)
|
def _build_left_panel(self):
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
|
||||||
release_frame = Gtk.Frame(label="Metadata / Releases")
|
release_frame = Gtk.Frame(label="Metadata / Releases")
|
||||||
release_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin=8)
|
release_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=6)
|
||||||
release_frame.add(release_box)
|
release_frame.add(release_box)
|
||||||
|
|
||||||
self.release_store = Gtk.ListStore(str, str, str, str, str, object)
|
self.release_store = Gtk.ListStore(str, str, str, str, str, object)
|
||||||
self.release_view = Gtk.TreeView(model=self.release_store)
|
self.release_view = Gtk.TreeView(model=self.release_store)
|
||||||
self.release_view.set_headers_clickable(False)
|
self.release_view.set_headers_clickable(False)
|
||||||
self.release_view.set_enable_search(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.set_grid_lines(Gtk.TreeViewGridLines.BOTH)
|
||||||
self.release_view.get_style_context().add_class("eac-matrix")
|
|
||||||
self.release_view.get_selection().connect("changed", self._on_release_selected)
|
self.release_view.get_selection().connect("changed", self._on_release_selected)
|
||||||
for index, title in enumerate(["Artist", "Title", "Year", "Type", "Country"]):
|
for index, title in enumerate(["Artist", "Title", "Year", "Type", "Country"]):
|
||||||
renderer = Gtk.CellRendererText()
|
renderer = Gtk.CellRendererText()
|
||||||
@@ -416,6 +443,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
scroll = Gtk.ScrolledWindow()
|
scroll = Gtk.ScrolledWindow()
|
||||||
scroll.set_hexpand(True)
|
scroll.set_hexpand(True)
|
||||||
scroll.set_vexpand(True)
|
scroll.set_vexpand(True)
|
||||||
|
scroll.set_min_content_height(180)
|
||||||
scroll.add(self.release_view)
|
scroll.add(self.release_view)
|
||||||
release_box.pack_start(scroll, True, True, 0)
|
release_box.pack_start(scroll, True, True, 0)
|
||||||
|
|
||||||
@@ -423,25 +451,24 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
details_scroll = Gtk.ScrolledWindow()
|
details_scroll = Gtk.ScrolledWindow()
|
||||||
details_scroll.set_hexpand(True)
|
details_scroll.set_hexpand(True)
|
||||||
details_scroll.set_vexpand(False)
|
details_scroll.set_vexpand(False)
|
||||||
details_scroll.set_min_content_height(120)
|
details_scroll.set_min_content_height(90)
|
||||||
details_frame.add(details_scroll)
|
details_frame.add(details_scroll)
|
||||||
|
|
||||||
self.release_details = Gtk.Label(label="Select a release to inspect it.", xalign=0, yalign=0)
|
self.release_details = Gtk.Label(label="Select a release to inspect it.", xalign=0, yalign=0)
|
||||||
self.release_details.set_line_wrap(True)
|
self.release_details.set_line_wrap(True)
|
||||||
self.release_details.set_selectable(True)
|
self.release_details.set_selectable(True)
|
||||||
self.release_details.get_style_context().add_class("eac-subtle")
|
|
||||||
details_scroll.add(self.release_details)
|
details_scroll.add(self.release_details)
|
||||||
release_box.pack_start(details_frame, False, False, 0)
|
release_box.pack_start(details_frame, False, False, 0)
|
||||||
|
|
||||||
box.pack_start(release_frame, True, True, 0)
|
box.pack_start(release_frame, True, True, 0)
|
||||||
box.set_size_request(420, -1)
|
box.set_size_request(330, -1)
|
||||||
return box
|
return box
|
||||||
|
|
||||||
def _build_right_panel(self):
|
def _build_right_panel(self):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
|
||||||
tracks_frame = Gtk.Frame(label="Track Extraction Matrix")
|
tracks_frame = Gtk.Frame(label="Track Extraction Matrix")
|
||||||
tracks_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=8)
|
tracks_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin=6)
|
||||||
tracks_frame.add(tracks_box)
|
tracks_frame.add(tracks_box)
|
||||||
|
|
||||||
self.track_columns = {
|
self.track_columns = {
|
||||||
@@ -458,9 +485,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.track_view = Gtk.TreeView(model=self.track_store)
|
self.track_view = Gtk.TreeView(model=self.track_store)
|
||||||
self.track_view.set_headers_clickable(False)
|
self.track_view.set_headers_clickable(False)
|
||||||
self.track_view.set_enable_search(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)
|
self.track_view.set_grid_lines(Gtk.TreeViewGridLines.BOTH)
|
||||||
self.track_view.get_style_context().add_class("eac-matrix")
|
|
||||||
for index, title in enumerate(["#", "Status", "Artist", "Title", "Length", "Test CRC", "Copy CRC", "AR"]):
|
for index, title in enumerate(["#", "Status", "Artist", "Title", "Length", "Test CRC", "Copy CRC", "AR"]):
|
||||||
renderer = Gtk.CellRendererText()
|
renderer = Gtk.CellRendererText()
|
||||||
column = Gtk.TreeViewColumn(title, renderer, text=index)
|
column = Gtk.TreeViewColumn(title, renderer, text=index)
|
||||||
@@ -474,77 +499,35 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
track_scroll = Gtk.ScrolledWindow()
|
track_scroll = Gtk.ScrolledWindow()
|
||||||
track_scroll.set_hexpand(True)
|
track_scroll.set_hexpand(True)
|
||||||
track_scroll.set_vexpand(True)
|
track_scroll.set_vexpand(True)
|
||||||
|
track_scroll.set_min_content_height(220)
|
||||||
track_scroll.add(self.track_view)
|
track_scroll.add(self.track_view)
|
||||||
tracks_box.pack_start(track_scroll, True, True, 0)
|
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(tracks_frame, True, True, 0)
|
||||||
return box
|
return box
|
||||||
|
|
||||||
def _build_rip_options(self):
|
def _build_rip_options(self):
|
||||||
frame = Gtk.Frame(label="Extraction Setup")
|
frame = Gtk.Frame(label="Extraction Setup")
|
||||||
grid = Gtk.Grid(column_spacing=8, row_spacing=6, margin=8)
|
grid = Gtk.Grid(column_spacing=8, row_spacing=4, margin=6)
|
||||||
frame.add(grid)
|
frame.add(grid)
|
||||||
|
|
||||||
self.unknown_check = Gtk.CheckButton(label="Allow unknown disc")
|
grid.attach(Gtk.Label(label="Track Tpl", xalign=0), 0, 0, 1, 1)
|
||||||
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="Continue 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="Track Tpl", xalign=0), 0, 4, 1, 1)
|
|
||||||
self.track_template_entry = Gtk.Entry()
|
self.track_template_entry = Gtk.Entry()
|
||||||
self.track_template_entry.set_text(cd_command.DEFAULT_TRACK_TEMPLATE)
|
self.track_template_entry.set_text(cd_command.DEFAULT_TRACK_TEMPLATE)
|
||||||
self.track_template_entry.connect("changed", self._on_settings_changed)
|
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 = Gtk.Entry()
|
||||||
self.disc_template_entry.set_text(cd_command.DEFAULT_DISC_TEMPLATE)
|
self.disc_template_entry.set_text(cd_command.DEFAULT_DISC_TEMPLATE)
|
||||||
self.disc_template_entry.connect("changed", self._on_settings_changed)
|
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(
|
note = Gtk.Label(
|
||||||
label="Secure extraction is driven by whipper's native cdparanoia/cdrdao pipeline. The configured drive offset is loaded automatically when available.",
|
label="Templates are kept here so the lower workspace can stay focused on releases, tracks and log visibility.",
|
||||||
xalign=0,
|
xalign=0,
|
||||||
)
|
)
|
||||||
note.set_line_wrap(True)
|
note.set_line_wrap(True)
|
||||||
note.get_style_context().add_class("eac-subtle")
|
grid.attach(note, 0, 2, 4, 1)
|
||||||
grid.attach(note, 0, 6, 4, 1)
|
|
||||||
|
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
@@ -553,14 +536,13 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
scroll = Gtk.ScrolledWindow()
|
scroll = Gtk.ScrolledWindow()
|
||||||
scroll.set_hexpand(True)
|
scroll.set_hexpand(True)
|
||||||
scroll.set_vexpand(False)
|
scroll.set_vexpand(False)
|
||||||
scroll.set_min_content_height(220)
|
scroll.set_min_content_height(150)
|
||||||
frame.add(scroll)
|
frame.add(scroll)
|
||||||
|
|
||||||
text_view = Gtk.TextView()
|
text_view = Gtk.TextView()
|
||||||
text_view.set_editable(False)
|
text_view.set_editable(False)
|
||||||
text_view.set_cursor_visible(False)
|
text_view.set_cursor_visible(False)
|
||||||
text_view.set_monospace(True)
|
text_view.set_monospace(True)
|
||||||
text_view.get_style_context().add_class("eac-log")
|
|
||||||
self.log_buffer = text_view.get_buffer()
|
self.log_buffer = text_view.get_buffer()
|
||||||
scroll.add(text_view)
|
scroll.add(text_view)
|
||||||
return frame
|
return frame
|
||||||
@@ -686,6 +668,8 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
def _set_running_state(self, running, status):
|
def _set_running_state(self, running, status):
|
||||||
self.read_button.set_sensitive(not running)
|
self.read_button.set_sensitive(not running)
|
||||||
self.refresh_button.set_sensitive(not running)
|
self.refresh_button.set_sensitive(not running)
|
||||||
|
self.analyze_button.set_sensitive(not running)
|
||||||
|
self.find_offset_button.set_sensitive(not running)
|
||||||
self.rip_button.set_sensitive((not running) and self._can_rip())
|
self.rip_button.set_sensitive((not running) and self._can_rip())
|
||||||
self.stop_button.set_sensitive(running)
|
self.stop_button.set_sensitive(running)
|
||||||
self.status_label.set_text(status)
|
self.status_label.set_text(status)
|
||||||
@@ -697,7 +681,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.pulse_id = 0
|
self.pulse_id = 0
|
||||||
|
|
||||||
def _pulse_progress(self):
|
def _pulse_progress(self):
|
||||||
if self.scan_runner is None and self.rip_runner is None:
|
if self.scan_runner is None and self.rip_runner is None and self.drive_runner is None:
|
||||||
return False
|
return False
|
||||||
self.track_bar.pulse()
|
self.track_bar.pulse()
|
||||||
return True
|
return True
|
||||||
@@ -714,9 +698,14 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.device_combo.set_active(0)
|
self.device_combo.set_active(0)
|
||||||
self.read_button.set_sensitive(False)
|
self.read_button.set_sensitive(False)
|
||||||
self.rip_button.set_sensitive(False)
|
self.rip_button.set_sensitive(False)
|
||||||
|
self.analyze_button.set_sensitive(False)
|
||||||
|
self.find_offset_button.set_sensitive(False)
|
||||||
logger.warning("no optical drives detected")
|
logger.warning("no optical drives detected")
|
||||||
|
self._update_drive_info(None)
|
||||||
else:
|
else:
|
||||||
self.read_button.set_sensitive(True)
|
self.read_button.set_sensitive(True)
|
||||||
|
self.analyze_button.set_sensitive(True)
|
||||||
|
self.find_offset_button.set_sensitive(True)
|
||||||
if current in devices:
|
if current in devices:
|
||||||
self.device_combo.set_active(devices.index(current))
|
self.device_combo.set_active(devices.index(current))
|
||||||
else:
|
else:
|
||||||
@@ -724,6 +713,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
logger.info("detected %d optical drive(s); active device: %s",
|
logger.info("detected %d optical drive(s); active device: %s",
|
||||||
len(devices), self.device_combo.get_active_text())
|
len(devices), self.device_combo.get_active_text())
|
||||||
self._apply_configured_offset()
|
self._apply_configured_offset()
|
||||||
|
self._update_drive_info(self.device_combo.get_active_text())
|
||||||
|
|
||||||
def _selected_device(self):
|
def _selected_device(self):
|
||||||
device = self.device_combo.get_active_text()
|
device = self.device_combo.get_active_text()
|
||||||
@@ -731,20 +721,64 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
return None
|
return None
|
||||||
return device
|
return device
|
||||||
|
|
||||||
|
def _update_drive_info(self, device):
|
||||||
|
self._set_label("device", device)
|
||||||
|
for key in ["vendor", "model", "release", "read_offset", "cache_defeat"]:
|
||||||
|
self._set_label(key, None)
|
||||||
|
if not device:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
info = drive.getDeviceInfo(device)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to read drive info for %s", device)
|
||||||
|
return
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
vendor, model, release = info
|
||||||
|
self._set_label("vendor", vendor)
|
||||||
|
self._set_label("model", model)
|
||||||
|
self._set_label("release", release)
|
||||||
|
conf = config.Config()
|
||||||
|
try:
|
||||||
|
self._set_label("read_offset", str(conf.getReadOffset(vendor, model, release)))
|
||||||
|
except KeyError:
|
||||||
|
self._set_label("read_offset", "Unknown")
|
||||||
|
try:
|
||||||
|
defeats_cache = conf.getDefeatsCache(vendor, model, release)
|
||||||
|
self._set_label("cache_defeat", "Yes" if defeats_cache else "No")
|
||||||
|
except KeyError:
|
||||||
|
self._set_label("cache_defeat", "Unknown")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_offset_candidates(offsets_text):
|
||||||
|
offsets = []
|
||||||
|
for block in offsets_text.split(","):
|
||||||
|
block = block.strip()
|
||||||
|
if not block:
|
||||||
|
continue
|
||||||
|
if ":" in block:
|
||||||
|
start, end = block.split(":")
|
||||||
|
offsets.extend(range(int(start), int(end) + 1))
|
||||||
|
else:
|
||||||
|
offsets.append(int(block))
|
||||||
|
return offsets
|
||||||
|
|
||||||
def _update_release_store(self, releases):
|
def _update_release_store(self, releases):
|
||||||
self.release_store.clear()
|
self.release_store.clear()
|
||||||
self.current_release = None
|
self.current_release = None
|
||||||
for metadata in releases:
|
for metadata in releases:
|
||||||
self.release_store.append([
|
self.release_store.append([
|
||||||
metadata.artist or "",
|
metadata.artist or "Unknown Artist",
|
||||||
metadata.releaseTitle or metadata.title or "",
|
metadata.releaseTitle or metadata.title or "Unknown Release",
|
||||||
_release_year(metadata),
|
_release_year(metadata),
|
||||||
metadata.releaseType or "",
|
metadata.releaseType or "Unknown",
|
||||||
_release_country(metadata),
|
_release_country(metadata) or "—",
|
||||||
metadata,
|
metadata,
|
||||||
])
|
])
|
||||||
if releases:
|
if releases:
|
||||||
self.release_view.get_selection().select_path(0)
|
self.release_view.get_selection().select_path(0)
|
||||||
|
self.release_view.set_cursor(Gtk.TreePath.new_first())
|
||||||
|
self.release_view.scroll_to_cell(Gtk.TreePath.new_first(), None, False, 0.0, 0.0)
|
||||||
else:
|
else:
|
||||||
self._update_track_store(None)
|
self._update_track_store(None)
|
||||||
self._update_release_details(None)
|
self._update_release_details(None)
|
||||||
@@ -817,6 +851,178 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.offset_spin.set_value(int(offset))
|
self.offset_spin.set_value(int(offset))
|
||||||
logger.info("loaded configured read offset %d for %s", offset, device)
|
logger.info("loaded configured read offset %d for %s", offset, device)
|
||||||
|
|
||||||
|
def _analyze_drive_worker(self, device):
|
||||||
|
try:
|
||||||
|
self.drive_cancel_requested = False
|
||||||
|
GLib.idle_add(self._log_action, "Drive analysis requested for %s", device)
|
||||||
|
GLib.idle_add(self._set_running_state, True, "Analyzing drive")
|
||||||
|
GLib.idle_add(self.progress_label.set_text, "Analyzing drive cache behaviour")
|
||||||
|
|
||||||
|
runner = CancellableSyncRunner()
|
||||||
|
self.drive_runner = runner
|
||||||
|
analyze_task = cdparanoia.AnalyzeTask(device)
|
||||||
|
runner.run(analyze_task)
|
||||||
|
if self.drive_cancel_requested:
|
||||||
|
raise RuntimeError("Drive analysis cancelled")
|
||||||
|
if analyze_task.defeatsCache is None:
|
||||||
|
raise RuntimeError("Cannot analyze the drive; insert an audio CD and retry.")
|
||||||
|
|
||||||
|
info = drive.getDeviceInfo(device)
|
||||||
|
if info:
|
||||||
|
config.Config().setDefeatsCache(info[0], info[1], info[2], analyze_task.defeatsCache)
|
||||||
|
|
||||||
|
def apply_results():
|
||||||
|
self._log_action(
|
||||||
|
"Drive cache analysis result: cdparanoia %s defeat the audio cache",
|
||||||
|
"can" if analyze_task.defeatsCache else "cannot",
|
||||||
|
)
|
||||||
|
self._update_drive_info(device)
|
||||||
|
self._set_running_state(False, "Drive analyzed")
|
||||||
|
self.progress_label.set_text("Drive analysis complete")
|
||||||
|
return False
|
||||||
|
|
||||||
|
GLib.idle_add(apply_results)
|
||||||
|
except Exception as exc:
|
||||||
|
def apply_error():
|
||||||
|
if self.drive_cancel_requested:
|
||||||
|
self._log_action("Drive analysis cancelled", level=logging.WARNING)
|
||||||
|
self._set_running_state(False, "Cancelled")
|
||||||
|
self.progress_label.set_text("Drive analysis cancelled")
|
||||||
|
else:
|
||||||
|
self._log_action("%s", exc, level=logging.ERROR)
|
||||||
|
self._set_running_state(False, "Drive analysis failed")
|
||||||
|
self.progress_label.set_text("Drive analysis failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
GLib.idle_add(apply_error)
|
||||||
|
finally:
|
||||||
|
self.drive_runner = None
|
||||||
|
|
||||||
|
def _find_offset_worker(self, device):
|
||||||
|
try:
|
||||||
|
self.drive_cancel_requested = False
|
||||||
|
GLib.idle_add(self._log_action, "Offset detection requested for %s", device)
|
||||||
|
GLib.idle_add(self._set_running_state, True, "Finding offset")
|
||||||
|
GLib.idle_add(self.progress_label.set_text, "Finding drive read offset")
|
||||||
|
|
||||||
|
runner = CancellableSyncRunner()
|
||||||
|
self.drive_runner = runner
|
||||||
|
|
||||||
|
utils.load_device(device)
|
||||||
|
utils.unmount_device(device)
|
||||||
|
toc_task = cdrdao.ReadTOCTask(device)
|
||||||
|
runner.run(toc_task)
|
||||||
|
table = toc_task.toc.table
|
||||||
|
if len(table.tracks) < 3:
|
||||||
|
raise RuntimeError("Offset detection needs a CD with at least 3 tracks.")
|
||||||
|
|
||||||
|
responses = accurip.get_db_entry(table.accuraterip_path())
|
||||||
|
offsets = self._parse_offset_candidates(offset_command.OFFSETS)
|
||||||
|
|
||||||
|
def match(archecksums, track_number):
|
||||||
|
for index, response in enumerate(responses):
|
||||||
|
for checksum in archecksums:
|
||||||
|
if checksum == response.checksums[track_number - 1]:
|
||||||
|
return checksum, index
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def rip_arcs(track_number, offset_value):
|
||||||
|
fd, path = tempfile.mkstemp(
|
||||||
|
suffix=".track%02d.offset%d.whipper.wav" % (track_number, offset_value)
|
||||||
|
)
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
read_task = cdparanoia.ReadTrackTask(
|
||||||
|
path,
|
||||||
|
table,
|
||||||
|
table.getTrackStart(track_number),
|
||||||
|
table.getTrackEnd(track_number),
|
||||||
|
overread=False,
|
||||||
|
offset=offset_value,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
read_task.description = "Ripping track %d with read offset %d" % (
|
||||||
|
track_number,
|
||||||
|
offset_value,
|
||||||
|
)
|
||||||
|
runner.run(read_task)
|
||||||
|
v1, v2 = arc.accuraterip_checksum(path, track_number, len(table.tracks))
|
||||||
|
return "%08x" % v1, "%08x" % v2
|
||||||
|
finally:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
found_offset = None
|
||||||
|
for offset_value in offsets:
|
||||||
|
if self.drive_cancel_requested:
|
||||||
|
raise RuntimeError("Offset detection cancelled")
|
||||||
|
GLib.idle_add(self._log_action, "Trying read offset %d", offset_value)
|
||||||
|
try:
|
||||||
|
checksums = rip_arcs(1, offset_value)
|
||||||
|
except Exception as exc:
|
||||||
|
GLib.idle_add(self._log_action, "Cannot rip with offset %d: %s", offset_value, exc, level=logging.WARNING)
|
||||||
|
continue
|
||||||
|
checksum, response_index = match(checksums, 1)
|
||||||
|
if not checksum:
|
||||||
|
continue
|
||||||
|
|
||||||
|
GLib.idle_add(self._log_action, "Potential offset %d matched response %d; confirming", offset_value, response_index)
|
||||||
|
matched_tracks = 1
|
||||||
|
for track_number in range(2, len(table.tracks)):
|
||||||
|
if self.drive_cancel_requested:
|
||||||
|
raise RuntimeError("Offset detection cancelled")
|
||||||
|
try:
|
||||||
|
checksums = rip_arcs(track_number, offset_value)
|
||||||
|
except Exception as exc:
|
||||||
|
GLib.idle_add(self._log_action, "Track %d failed for offset %d: %s", track_number, offset_value, exc, level=logging.WARNING)
|
||||||
|
continue
|
||||||
|
checksum, _ = match(checksums, track_number)
|
||||||
|
if checksum:
|
||||||
|
matched_tracks += 1
|
||||||
|
if matched_tracks == len(table.tracks) - 1:
|
||||||
|
found_offset = offset_value
|
||||||
|
break
|
||||||
|
|
||||||
|
if found_offset is None:
|
||||||
|
raise RuntimeError("No matching offset found. Try another AccurateRip-enabled disc.")
|
||||||
|
|
||||||
|
info = drive.getDeviceInfo(device)
|
||||||
|
if info:
|
||||||
|
config.Config().setReadOffset(info[0], info[1], info[2], found_offset)
|
||||||
|
|
||||||
|
def apply_results():
|
||||||
|
self.offset_spin.set_value(found_offset)
|
||||||
|
self._log_action("Read offset of device is %d", found_offset)
|
||||||
|
self._update_drive_info(device)
|
||||||
|
self._set_running_state(False, "Offset found")
|
||||||
|
self.progress_label.set_text("Drive offset detection complete")
|
||||||
|
return False
|
||||||
|
|
||||||
|
GLib.idle_add(apply_results)
|
||||||
|
except accurip.EntryNotFound:
|
||||||
|
def apply_not_found():
|
||||||
|
self._log_action("AccurateRip entry not found; try another disc for offset detection", level=logging.WARNING)
|
||||||
|
self._set_running_state(False, "Offset unavailable")
|
||||||
|
self.progress_label.set_text("No AccurateRip entry for this disc")
|
||||||
|
return False
|
||||||
|
|
||||||
|
GLib.idle_add(apply_not_found)
|
||||||
|
except Exception as exc:
|
||||||
|
def apply_error():
|
||||||
|
if self.drive_cancel_requested:
|
||||||
|
self._log_action("Offset detection cancelled", level=logging.WARNING)
|
||||||
|
self._set_running_state(False, "Cancelled")
|
||||||
|
self.progress_label.set_text("Offset detection cancelled")
|
||||||
|
else:
|
||||||
|
self._log_action("%s", exc, level=logging.ERROR)
|
||||||
|
self._set_running_state(False, "Offset detection failed")
|
||||||
|
self.progress_label.set_text("Offset detection failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
GLib.idle_add(apply_error)
|
||||||
|
finally:
|
||||||
|
self.drive_runner = None
|
||||||
|
|
||||||
def _read_disc_worker(self, device, country):
|
def _read_disc_worker(self, device, country):
|
||||||
try:
|
try:
|
||||||
self.scan_cancel_requested = False
|
self.scan_cancel_requested = False
|
||||||
@@ -869,7 +1075,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
"tracks": track_count,
|
"tracks": track_count,
|
||||||
"releases": releases,
|
"releases": releases,
|
||||||
}
|
}
|
||||||
self._set_label("device", device)
|
self._update_drive_info(device)
|
||||||
self._set_label("disc_status", "Ready")
|
self._set_label("disc_status", "Ready")
|
||||||
self._set_label("cddb", cddb)
|
self._set_label("cddb", cddb)
|
||||||
self._set_label("mbid", mbid)
|
self._set_label("mbid", mbid)
|
||||||
@@ -894,7 +1100,7 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.release_store.clear()
|
self.release_store.clear()
|
||||||
self.track_store.clear()
|
self.track_store.clear()
|
||||||
self._update_release_details(None)
|
self._update_release_details(None)
|
||||||
self._set_label("device", device)
|
self._update_drive_info(device)
|
||||||
if self.scan_cancel_requested:
|
if self.scan_cancel_requested:
|
||||||
self._set_label("disc_status", "Cancelled")
|
self._set_label("disc_status", "Cancelled")
|
||||||
self._log_action("Disc scan cancelled", level=logging.WARNING)
|
self._log_action("Disc scan cancelled", level=logging.WARNING)
|
||||||
@@ -1429,7 +1635,34 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self._save_gui_settings()
|
self._save_gui_settings()
|
||||||
device = self._selected_device()
|
device = self._selected_device()
|
||||||
if device:
|
if device:
|
||||||
|
self._update_drive_info(device)
|
||||||
self._log_action("Selected device %s", device)
|
self._log_action("Selected device %s", device)
|
||||||
|
else:
|
||||||
|
self._update_drive_info(None)
|
||||||
|
|
||||||
|
def _on_analyze_drive_clicked(self, _button):
|
||||||
|
device = self._selected_device()
|
||||||
|
if not device:
|
||||||
|
self._log_action("Drive analysis requested without an active device", level=logging.WARNING)
|
||||||
|
return
|
||||||
|
self.worker = threading.Thread(
|
||||||
|
target=self._analyze_drive_worker,
|
||||||
|
args=(device,),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self.worker.start()
|
||||||
|
|
||||||
|
def _on_find_offset_clicked(self, _button):
|
||||||
|
device = self._selected_device()
|
||||||
|
if not device:
|
||||||
|
self._log_action("Offset detection requested without an active device", level=logging.WARNING)
|
||||||
|
return
|
||||||
|
self.worker = threading.Thread(
|
||||||
|
target=self._find_offset_worker,
|
||||||
|
args=(device,),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self.worker.start()
|
||||||
|
|
||||||
def _on_read_clicked(self, _button):
|
def _on_read_clicked(self, _button):
|
||||||
device = self._selected_device()
|
device = self._selected_device()
|
||||||
@@ -1485,6 +1718,10 @@ class WhipperGui(Gtk.Application if Gtk is not None else object):
|
|||||||
self.rip_cancel_requested = True
|
self.rip_cancel_requested = True
|
||||||
self._log_action("Stopping rip", level=logging.WARNING)
|
self._log_action("Stopping rip", level=logging.WARNING)
|
||||||
self.rip_runner.cancel()
|
self.rip_runner.cancel()
|
||||||
|
elif self.drive_runner is not None:
|
||||||
|
self.drive_cancel_requested = True
|
||||||
|
self._log_action("Stopping drive operation", level=logging.WARNING)
|
||||||
|
self.drive_runner.cancel()
|
||||||
|
|
||||||
|
|
||||||
class GuiLogHandler(logging.Handler):
|
class GuiLogHandler(logging.Handler):
|
||||||
|
|||||||
@@ -169,10 +169,18 @@ class FakeSelection:
|
|||||||
class FakeReleaseView:
|
class FakeReleaseView:
|
||||||
def __init__(self, store):
|
def __init__(self, store):
|
||||||
self.selection = FakeSelection(store)
|
self.selection = FakeSelection(store)
|
||||||
|
self.cursor = None
|
||||||
|
self.scrolled_path = None
|
||||||
|
|
||||||
def get_selection(self):
|
def get_selection(self):
|
||||||
return self.selection
|
return self.selection
|
||||||
|
|
||||||
|
def set_cursor(self, path):
|
||||||
|
self.cursor = path
|
||||||
|
|
||||||
|
def scroll_to_cell(self, path, *_args):
|
||||||
|
self.scrolled_path = path
|
||||||
|
|
||||||
|
|
||||||
class FakeRunner:
|
class FakeRunner:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -269,6 +277,8 @@ def _make_ui_app(tmp_path):
|
|||||||
app.disc_template_entry = FakeEntry("%d")
|
app.disc_template_entry = FakeEntry("%d")
|
||||||
app.read_button = FakeButton()
|
app.read_button = FakeButton()
|
||||||
app.refresh_button = FakeButton()
|
app.refresh_button = FakeButton()
|
||||||
|
app.analyze_button = FakeButton()
|
||||||
|
app.find_offset_button = FakeButton()
|
||||||
app.rip_button = FakeButton()
|
app.rip_button = FakeButton()
|
||||||
app.stop_button = FakeButton()
|
app.stop_button = FakeButton()
|
||||||
app.status_label = FakeLabel()
|
app.status_label = FakeLabel()
|
||||||
@@ -290,7 +300,8 @@ def _make_ui_app(tmp_path):
|
|||||||
app.release_view = FakeReleaseView(app.release_store)
|
app.release_view = FakeReleaseView(app.release_store)
|
||||||
app.release_details = FakeLabel()
|
app.release_details = FakeLabel()
|
||||||
app.info_labels = {key: FakeLabel() for key in [
|
app.info_labels = {key: FakeLabel() for key in [
|
||||||
"device", "disc_status", "cddb", "mbid", "duration", "tracks"
|
"device", "vendor", "model", "release", "read_offset", "cache_defeat",
|
||||||
|
"disc_status", "cddb", "mbid", "duration", "tracks"
|
||||||
]}
|
]}
|
||||||
app.scan_runner = None
|
app.scan_runner = None
|
||||||
app.rip_runner = None
|
app.rip_runner = None
|
||||||
@@ -313,6 +324,7 @@ def _make_ui_app(tmp_path):
|
|||||||
"_can_rip",
|
"_can_rip",
|
||||||
"_set_label",
|
"_set_label",
|
||||||
"_set_running_state",
|
"_set_running_state",
|
||||||
|
"_update_drive_info",
|
||||||
"_update_release_store",
|
"_update_release_store",
|
||||||
"_update_track_store",
|
"_update_track_store",
|
||||||
"_set_track_field",
|
"_set_track_field",
|
||||||
@@ -323,6 +335,8 @@ def _make_ui_app(tmp_path):
|
|||||||
"_pulse_progress",
|
"_pulse_progress",
|
||||||
"_resolve_release_metadata",
|
"_resolve_release_metadata",
|
||||||
"_on_release_selected",
|
"_on_release_selected",
|
||||||
|
"_on_analyze_drive_clicked",
|
||||||
|
"_on_find_offset_clicked",
|
||||||
"_on_stop_clicked",
|
"_on_stop_clicked",
|
||||||
)
|
)
|
||||||
app._remove_gui_log_handler = gui.WhipperGui._remove_gui_log_handler
|
app._remove_gui_log_handler = gui.WhipperGui._remove_gui_log_handler
|
||||||
@@ -409,6 +423,64 @@ def test_release_selection_and_progress_updates(monkeypatch, tmp_path):
|
|||||||
assert app.progress_label.get_text() == "Rip complete"
|
assert app.progress_label.get_text() == "Rip complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_release_store_uses_visible_fallbacks(tmp_path):
|
||||||
|
app = _make_ui_app(tmp_path)
|
||||||
|
metadata = SimpleNamespace(
|
||||||
|
artist=None,
|
||||||
|
releaseTitle=None,
|
||||||
|
title=None,
|
||||||
|
release=None,
|
||||||
|
releaseType=None,
|
||||||
|
countries=[],
|
||||||
|
duration=1000,
|
||||||
|
tracks=[],
|
||||||
|
discNumber=1,
|
||||||
|
discTotal=1,
|
||||||
|
mbid="mbid-empty",
|
||||||
|
url="https://example.invalid/mbid-empty",
|
||||||
|
barcode=None,
|
||||||
|
catalogNumbers=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
gui.WhipperGui._update_release_store(app, [metadata])
|
||||||
|
|
||||||
|
assert app.release_store[0][0] == "Unknown Artist"
|
||||||
|
assert app.release_store[0][1] == "Unknown Release"
|
||||||
|
assert app.release_store[0][3] == "Unknown"
|
||||||
|
assert app.release_store[0][4] == "—"
|
||||||
|
assert app.release_view.cursor is not None
|
||||||
|
assert app.release_view.scrolled_path is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_drive_info_reads_config(monkeypatch, tmp_path):
|
||||||
|
app = _make_ui_app(tmp_path)
|
||||||
|
|
||||||
|
class FakeConfig:
|
||||||
|
def getReadOffset(self, vendor, model, release):
|
||||||
|
assert (vendor, model, release) == ("Vendor", "Model", "1.0")
|
||||||
|
return 667
|
||||||
|
|
||||||
|
def getDefeatsCache(self, vendor, model, release):
|
||||||
|
assert (vendor, model, release) == ("Vendor", "Model", "1.0")
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(gui.drive, "getDeviceInfo", lambda device: ("Vendor", "Model", "1.0"))
|
||||||
|
monkeypatch.setattr(gui.config, "Config", lambda: FakeConfig())
|
||||||
|
|
||||||
|
gui.WhipperGui._update_drive_info(app, "/dev/cdrom")
|
||||||
|
|
||||||
|
assert app.info_labels["device"].get_text() == "/dev/cdrom"
|
||||||
|
assert app.info_labels["vendor"].get_text() == "Vendor"
|
||||||
|
assert app.info_labels["model"].get_text() == "Model"
|
||||||
|
assert app.info_labels["release"].get_text() == "1.0"
|
||||||
|
assert app.info_labels["read_offset"].get_text() == "667"
|
||||||
|
assert app.info_labels["cache_defeat"].get_text() == "Yes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_offset_candidates_supports_ranges(tmp_path):
|
||||||
|
assert gui.WhipperGui._parse_offset_candidates("-1, 0, 2:4") == [-1, 0, 2, 3, 4]
|
||||||
|
|
||||||
|
|
||||||
def test_main_reports_missing_runtime(monkeypatch, capsys):
|
def test_main_reports_missing_runtime(monkeypatch, capsys):
|
||||||
monkeypatch.setattr(gui, "_GUI_IMPORT_ERROR", ImportError("missing"))
|
monkeypatch.setattr(gui, "_GUI_IMPORT_ERROR", ImportError("missing"))
|
||||||
monkeypatch.setattr(gui, "gi", None)
|
monkeypatch.setattr(gui, "gi", None)
|
||||||
|
|||||||
Reference in New Issue
Block a user