Add new features

This commit is contained in:
2026-04-18 17:55:52 +03:00
parent 992923bdc4
commit 56fd3a10c7
7 changed files with 1486 additions and 153 deletions

66
PKGBUILD Normal file
View File

@@ -0,0 +1,66 @@
pkgname=whipper-git
pkgver=0.10.0.r66.g992923b
pkgrel=1
pkgdesc='CD-DA ripper prioritising accuracy over speed'
arch=('x86_64')
url='https://github.com/whipper-team/whipper'
license=('GPL-3.0-or-later')
depends=(
'python'
'python-discid'
'python-musicbrainzngs'
'python-mutagen'
'python-pycdio'
'python-pygobject'
'python-ruamel-yaml'
'cdrdao'
'cdparanoia'
'flac'
'gtk3'
'libdiscid'
'libsndfile'
'sox'
)
makedepends=(
'git'
'gcc'
'python-build'
'python-installer'
'python-setuptools'
'python-setuptools-scm'
'python-wheel'
)
checkdepends=(
'python-pytest'
'python-twisted'
)
optdepends=(
'python-pillow: cover art embedding support'
'python-docutils: man page generation'
)
provides=('whipper')
conflicts=('whipper')
source=('git+https://git.daemonlord.ru/benya/whipper-gui.git')
sha256sums=('SKIP')
pkgver() {
cd "$srcdir/whipper"
git describe --long --tags --abbrev=7 | sed 's/^v//; s/\([^-]*-g\)/r\1/; s/-/./g'
}
build() {
cd "$srcdir/whipper"
python -m build --wheel --no-isolation
}
check() {
cd "$srcdir/whipper"
export HOME="$srcdir/home"
mkdir -p "$HOME"
pytest -q
}
package() {
cd "$srcdir/whipper"
python -m installer --destdir="$pkgdir" dist/*.whl
}

View File

@@ -28,6 +28,7 @@ In order to track whipper's latest changes it's advised to check its commit hist
3. [Fetching the source code](#fetching-the-source-code)
4. [Finalizing the build](#finalizing-the-build)
- [Usage](#usage)
- [GUI frontend](#gui-frontend)
- [Getting started](#getting-started)
- [Configuration file documentation](#configuration-file-documentation)
- [Running uninstalled](#running-uninstalled)
@@ -136,6 +137,7 @@ Whipper relies on the following packages in order to run correctly and provide a
- [pycdio](https://pypi.python.org/pypi/pycdio/), for drive identification (required for drive offset and caching behavior to be stored in the configuration file).
- To avoid bugs it's advised to use the most recent `pycdio` version with the corresponding `libcdio` release or, if stuck on old pycdio versions, **0.20**/**0.21** with `libcdio`**0.90****0.94**. All other combinations won't probably work.
- [discid](https://pypi.org/project/discid/), for calculating Musicbrainz disc id.
- [PyGObject](https://pypi.org/project/PyGObject/), for the GTK frontend (`whipper-gui`)
- [ruamel.yaml](https://pypi.org/project/ruamel.yaml/), for generating well formed YAML report logfiles
- [libsndfile](http://www.mega-nerd.com/libsndfile/), for reading wav files
- [flac](https://xiph.org/flac/), for reading flac files
@@ -147,6 +149,7 @@ Some dependencies aren't available in the PyPI. They can be probably installed u
- [cd-paranoia](https://github.com/rocky/libcdio-paranoia)
- [cdrdao](http://cdrdao.sourceforge.net/)
- GTK 3 / PyGObject runtime packages (distribution specific, required by `whipper-gui`)
- [libsndfile](http://www.mega-nerd.com/libsndfile/)
- [flac](https://xiph.org/flac/)
- [sox](http://sox.sourceforge.net/)
@@ -176,7 +179,7 @@ cd whipper
### Finalizing the build
Install whipper: `python3 setup.py install`
Install whipper: `python3 -m pip install .`
Note that, depending on the chosen installation path, this command may require elevated rights.
@@ -184,7 +187,12 @@ To build the man pages, follow the instructions in the relevant [README](https:/
## Usage
Whipper currently only has a command-line interface called `whipper` which is self-documenting: `whipper -h` gives you the basic instructions.
Whipper provides:
- a command-line interface called `whipper`
- a GTK frontend called `whipper-gui`
The CLI is still the most complete interface. `whipper -h` gives you the basic instructions.
Whipper implements a tree of commands: for example, the top-level `whipper` command has a number of sub-commands.
@@ -200,6 +208,35 @@ is not, because the `-d` argument applies to the `cd` command.
A more complete set of usage instructions can be found in the `whipper` [man pages](https://github.com/whipper-team/whipper/blob/develop/man/README.md).
## GUI frontend
`whipper-gui` is a GTK 3 frontend for the common `whipper cd` workflow.
Launch it with:
```bash
whipper-gui
```
The current GUI can:
- detect and select optical drives
- read the current disc TOC
- look up matching MusicBrainz releases
- inspect release and track metadata
- rip discs through whipper's native task pipeline
- keep a live log of the rip process
- cancel an in-progress TOC read or rip
- configure the logger, working directory, and disc/track templates
- remember its GUI settings in `$XDG_CONFIG_HOME/whipper/gui.json`
The current GUI does not yet expose every CLI feature. In particular, these options are still CLI-only:
- interactive release prompting (`whipper cd -p`)
- a few lower-level CLI workflows outside `whipper cd rip` such as `drive analyze` and `offset find`
The GUI uses whipper's internal API both for disc scanning and for the rip pipeline itself. It still lacks dedicated GUI workflows for some of the more specialized CLI tools.
## Getting started
The simplest way to get started making accurate rips is:
@@ -286,6 +323,7 @@ source checkout:
```bash
python3 -m whipper -h
python3 -m whipper.gui
```
## Logger plugins

File diff suppressed because it is too large Load Diff

View File

@@ -404,6 +404,10 @@ class ReadTrackTask(task.Task):
self.stop()
return
def abort(self):
if getattr(self, "_popen", None) is not None and self._popen.poll() is None:
self._popen.terminate()
class ReadVerifyTrackTask(task.MultiSeparateTask):
"""
@@ -566,6 +570,15 @@ class ReadVerifyTrackTask(task.MultiSeparateTask):
task.MultiSeparateTask.stop(self)
def abort(self):
if 0 < self._task <= len(self.tasks):
current_task = self.tasks[self._task - 1]
abort = getattr(current_task, "abort", None)
if callable(abort):
abort()
else:
current_task.stop()
_VERSION_RE = re.compile(
"^cdparanoia (?P<version>.+) release (?P<release>.+)")

View File

@@ -45,6 +45,12 @@ class PathTestCase(unittest.TestCase):
# TODO: Test cover art embedding too.
class CoverArtTestCase(unittest.TestCase):
def setUp(self):
self.cover_art_path = None
def tearDown(self):
if self.cover_art_path and os.path.exists(self.cover_art_path):
os.unlink(self.cover_art_path)
@staticmethod
def _mock_get_front_image(release_id):
@@ -90,5 +96,6 @@ class CoverArtTestCase(unittest.TestCase):
# https://musicbrainz.org/release/76df3287-6cda-33eb-8e9a-044b5e15ffdd
path = os.path.dirname(__file__)
release_id = "76df3287-6cda-33eb-8e9a-044b5e15ffdd"
coverArtPath = self._mock_getCoverArt(path, release_id)
self.assertTrue(os.path.isfile(coverArtPath))
self.cover_art_path = self._mock_getCoverArt(path, release_id)
self.assertEqual(self.cover_art_path, os.path.join(path, 'cover.jpg'))
self.assertTrue(os.path.isfile(self.cover_art_path))

679
whipper/test/test_gui.py Normal file
View File

@@ -0,0 +1,679 @@
import os
import threading
import time
from types import MethodType, SimpleNamespace
import pytest
from whipper import gui
def _bind_methods(app, *names):
for name in names:
setattr(app, name, MethodType(getattr(gui.WhipperGui, name), app))
return app
def _drain_glib_until(predicate, timeout=2.0):
context = gui.GLib.MainContext.default()
deadline = time.time() + timeout
while time.time() < deadline:
while context.pending():
context.iteration(False)
if predicate():
return
time.sleep(0.01)
while context.pending():
context.iteration(False)
assert predicate()
class FakeEntry:
def __init__(self, text=""):
self.text = text
def get_text(self):
return self.text
def set_text(self, value):
self.text = value
class FakeToggle:
def __init__(self, active=False):
self.active = active
def get_active(self):
return self.active
def set_active(self, value):
self.active = bool(value)
class FakeCombo:
def __init__(self, active_id=None):
self.active_id = active_id
def get_active_id(self):
return self.active_id
def set_active_id(self, value):
self.active_id = value
def set_active(self, index):
self.active_id = "" if index == 0 else self.active_id
class FakeSpin:
def __init__(self, value=0):
self.value = value
def get_value(self):
return self.value
def set_value(self, value):
self.value = value
class FakeButton:
def __init__(self):
self.sensitive = None
def set_sensitive(self, value):
self.sensitive = bool(value)
class FakeLabel:
def __init__(self, text=""):
self.text = text
def set_text(self, value):
self.text = value
def get_text(self):
return self.text
class FakeProgressBar:
def __init__(self):
self.fraction = 0.0
self.text = ""
self.pulses = 0
def set_fraction(self, value):
self.fraction = value
def set_text(self, value):
self.text = value
def pulse(self):
self.pulses += 1
class FakeFileChooser:
def __init__(self, filename=None):
self.filename = filename
def get_filename(self):
return self.filename
def set_filename(self, filename):
self.filename = filename
class FakeWindow:
def __init__(self, width=640, height=480):
self.size = (width, height)
def get_size(self):
return self.size
def resize(self, width, height):
self.size = (width, height)
class FakePane:
def __init__(self, position=0):
self.position = position
def get_position(self):
return self.position
def set_position(self, value):
self.position = value
class FakeListStore(list):
def clear(self):
del self[:]
def append(self, row):
super().append(row)
def get_value(self, tree_iter, index):
return self[tree_iter][index]
class FakeSelection:
def __init__(self, model):
self.model = model
self.selected = None
def get_selected(self):
return self.model, self.selected
def select_path(self, path):
self.selected = path
class FakeReleaseView:
def __init__(self, store):
self.selection = FakeSelection(store)
def get_selection(self):
return self.selection
class FakeRunner:
def __init__(self):
self.cancelled = False
def run(self, task, verbose=False):
self.task = task
def cancel(self):
self.cancelled = True
class FakeTrack:
def __init__(self, audio=True):
self.audio = audio
self.isrc = None
self.pre_emphasis = False
self.indexes = {
1: SimpleNamespace(relative=1),
}
def getPregap(self):
return 0
class FakeITable:
def __init__(self):
self.tracks = [FakeTrack(audio=True)]
class FakeResultTable:
def getTrackStart(self, _track_number):
return 0
def getTrackEnd(self, _track_number):
return 9
def getTrackLength(self, _track_number):
return 10
def setFile(self, *_args):
return None
class FakeRipResult:
def __init__(self):
self.tracks = []
self.table = FakeResultTable()
self.isCdr = False
def getTrackResult(self, track_number):
for track in self.tracks:
if track.number == track_number:
return track
return None
def _make_metadata(title, duration, mbid):
return SimpleNamespace(
artist="Artist",
releaseTitle=title,
title=title,
release="2024-01-01",
releaseType="Album",
countries=["US"],
duration=duration,
tracks=[],
discNumber=1,
discTotal=1,
mbid=mbid,
url="https://example.invalid/%s" % mbid,
barcode=None,
catalogNumbers=[],
)
def _make_ui_app(tmp_path):
app = SimpleNamespace()
app.window = FakeWindow(111, 222)
app.main_pane = FakePane(77)
app.output_button = FakeFileChooser(str(tmp_path))
app.working_directory_entry = FakeEntry("")
app.country_entry = FakeEntry("")
app.release_id_entry = FakeEntry("")
app.unknown_check = FakeToggle(True)
app.cdr_check = FakeToggle(False)
app.keep_going_check = FakeToggle(True)
app.overread_check = FakeToggle(False)
app.cover_art_combo = FakeCombo("")
app.max_retries_spin = FakeSpin(5)
app.offset_spin = FakeSpin(0)
app.logger_combo = FakeCombo("whipper")
app.track_template_entry = FakeEntry("%t")
app.disc_template_entry = FakeEntry("%d")
app.read_button = FakeButton()
app.refresh_button = FakeButton()
app.rip_button = FakeButton()
app.stop_button = FakeButton()
app.status_label = FakeLabel()
app.progress_label = FakeLabel()
app.overall_bar = FakeProgressBar()
app.track_bar = FakeProgressBar()
app.release_store = FakeListStore()
app.track_store = FakeListStore()
app.release_view = FakeReleaseView(app.release_store)
app.release_details = FakeLabel()
app.info_labels = {key: FakeLabel() for key in [
"device", "disc_status", "cddb", "mbid", "duration", "tracks"
]}
app.scan_runner = None
app.rip_runner = None
app.scan_cancel_requested = False
app.rip_cancel_requested = False
app.current_release = None
app.current_track_number = 0
app.current_track_total = 0
app.pulse_id = 0
app.logs = []
app._append_log = lambda text: app.logs.append(text)
app._clear_log = lambda: app.logs.clear()
app._config_path = lambda: tmp_path / "gui.json"
_bind_methods(
app,
"_collect_gui_settings",
"_save_gui_settings",
"_load_gui_settings",
"_can_rip",
"_set_label",
"_set_running_state",
"_update_release_store",
"_update_track_store",
"_update_release_details",
"_update_rip_task_progress",
"_finish_rip",
"_reset_progress",
"_pulse_progress",
"_resolve_release_metadata",
"_on_release_selected",
"_on_stop_clicked",
)
app._remove_gui_log_handler = gui.WhipperGui._remove_gui_log_handler
app._install_gui_log_handler = MethodType(gui.WhipperGui._install_gui_log_handler, app)
return app
def test_gui_settings_roundtrip(tmp_path, monkeypatch):
app = _make_ui_app(tmp_path)
app.output_button.set_filename(str(tmp_path / "output"))
os.mkdir(app.output_button.get_filename())
app.working_directory_entry.set_text(str(tmp_path))
app.country_entry.set_text("JP")
app.release_id_entry.set_text("release-id")
app.cdr_check.set_active(True)
app.cover_art_combo.set_active_id("embed")
app.max_retries_spin.set_value(7)
app.offset_spin.set_value(123)
app.track_template_entry.set_text("%A/%t")
app.disc_template_entry.set_text("%A/%d")
monkeypatch.setattr(gui.GLib, "idle_add", lambda func, *args: func(*args))
gui.WhipperGui._save_gui_settings(app)
app.output_button.set_filename(None)
app.working_directory_entry.set_text("")
app.country_entry.set_text("")
app.release_id_entry.set_text("")
app.unknown_check.set_active(False)
app.cdr_check.set_active(False)
app.keep_going_check.set_active(False)
app.overread_check.set_active(True)
app.cover_art_combo.set_active_id(None)
app.max_retries_spin.set_value(0)
app.offset_spin.set_value(0)
app.logger_combo.set_active_id(None)
app.track_template_entry.set_text("")
app.disc_template_entry.set_text("")
gui.WhipperGui._load_gui_settings(app)
assert app.output_button.get_filename() == str(tmp_path / "output")
assert app.working_directory_entry.get_text() == str(tmp_path)
assert app.country_entry.get_text() == "JP"
assert app.release_id_entry.get_text() == "release-id"
assert app.unknown_check.get_active() is True
assert app.cdr_check.get_active() is True
assert app.keep_going_check.get_active() is True
assert app.overread_check.get_active() is False
assert app.cover_art_combo.get_active_id() == "embed"
assert app.max_retries_spin.get_value() == 7
assert app.offset_spin.get_value() == 123
assert app.logger_combo.get_active_id() == "whipper"
assert app.track_template_entry.get_text() == "%A/%t"
assert app.disc_template_entry.get_text() == "%A/%d"
assert app.window.get_size() == (111, 222)
assert app.main_pane.get_position() == 77
def test_release_selection_and_progress_updates(monkeypatch, tmp_path):
app = _make_ui_app(tmp_path)
selection = app.release_view.get_selection()
metadata = _make_metadata("Chosen", 1000, "mbid-1")
metadata.tracks = [SimpleNamespace(artist="Track Artist", title="Track Title", duration=210000)]
app.release_store.append(["Artist", "Chosen", "2024", "Album", "US", metadata])
selection.select_path(0)
monkeypatch.setattr(gui.GLib, "timeout_add", lambda *_args: 99)
monkeypatch.setattr(gui.GLib, "source_remove", lambda *_args: None)
gui.WhipperGui._on_release_selected(app, selection)
gui.WhipperGui._set_running_state(app, True, "Busy")
assert app.read_button.sensitive is False
assert app.refresh_button.sensitive is False
assert app.stop_button.sensitive is True
gui.WhipperGui._update_rip_task_progress(app, "Encoding", "Track 1", 1, 2, 0.25)
gui.WhipperGui._finish_rip(app, 0, "Done")
assert app.current_release is metadata
assert app.track_store[0][1] == "Track Artist"
assert app.release_details.get_text().startswith("Artist: Artist")
assert app.overall_bar.fraction == 1.0
assert app.track_bar.fraction == 1.0
assert app.progress_label.get_text() == "Rip complete"
def test_main_reports_missing_runtime(monkeypatch, capsys):
monkeypatch.setattr(gui, "_GUI_IMPORT_ERROR", ImportError("missing"))
monkeypatch.setattr(gui, "gi", None)
monkeypatch.setattr(gui, "cdio", None)
assert gui.main() == 1
assert "whipper-gui requires" in capsys.readouterr().err
def test_read_disc_worker_processes_idle_callbacks(monkeypatch, tmp_path):
app = _make_ui_app(tmp_path)
calls = []
class FakeReadTOCTask:
def __init__(self, _device, fast_toc=True):
assert fast_toc is True
self.toc = SimpleNamespace(table=SimpleNamespace(
getCDDBDiscId=lambda: "cddb-id",
getMusicBrainzDiscId=lambda: "mbid-disc",
duration=lambda: 200000,
getAudioTracks=lambda: 10,
))
runner = FakeRunner()
monkeypatch.setattr(gui, "CancellableSyncRunner", lambda: runner)
monkeypatch.setattr(gui.cdrdao, "ReadTOCTask", FakeReadTOCTask)
monkeypatch.setattr(gui.utils, "load_device", lambda device: calls.append(("load", device)))
monkeypatch.setattr(gui.utils, "unmount_device", lambda device: calls.append(("unmount", device)))
monkeypatch.setattr(gui.drive, "get_cdrom_drive_status", lambda _device: 0)
monkeypatch.setattr(
gui.mbngs,
"musicbrainz",
lambda *_args, **_kwargs: [
_make_metadata("Far", 260000, "r2"),
_make_metadata("Near", 205000, "r1"),
],
)
monkeypatch.setattr(gui.GLib, "timeout_add", lambda *_args: 1)
monkeypatch.setattr(gui.GLib, "source_remove", lambda *_args: None)
worker = threading.Thread(
target=gui.WhipperGui._read_disc_worker,
args=(app, "/dev/cdrom", "US"),
)
worker.start()
_drain_glib_until(lambda: not worker.is_alive())
worker.join()
assert calls == [("load", "/dev/cdrom"), ("unmount", "/dev/cdrom")]
assert app.scan_data["mbid"] == "mbid-disc"
assert app.scan_data["releases"][0].releaseTitle == "Near"
assert app.info_labels["disc_status"].get_text() == "Ready"
assert "Found 2 matching release(s)" in "".join(app.logs)
def test_rip_disc_worker_success(monkeypatch, tmp_path):
app = _make_ui_app(tmp_path)
app.current_release = _make_metadata("Selected", 200000, "release-mbid")
app.release_id_entry.set_text("")
app.unknown_check.set_active(False)
app.scan_data = {"mbid": "mbid-disc", "releases": [app.current_release]}
program_holder = {}
class FakeProgram:
def __init__(self, _conf, record=False):
assert record is False
self.metadata = None
self.outdir = None
self.result = FakeRipResult()
self.cover_art_paths = []
self.write_cue = False
self.write_m3u_called = False
self.write_log_called = False
program_holder["program"] = self
def getFastToc(self, _runner, _device):
return SimpleNamespace(
getCDDBDiscId=lambda: "cddb-id",
getMusicBrainzDiscId=lambda: "mbid-disc",
)
def getRipResult(self):
return self.result
def getPath(self, base, _template, _mbdiscid, _metadata, track_number=None):
if track_number is None:
return os.path.join(base, "Artist - Selected", "Artist - Selected")
return os.path.join(base, "Artist - Selected", "track-%02d" % track_number)
def getTable(self, *_args):
return FakeITable()
def getHTOA(self):
return None
def getTagList(self, _track_number, _mbdiscid):
return {}
def getCoverArt(self, dirname, _mbid):
path = os.path.join(dirname, "cover.jpg")
with open(path, "wb") as handle:
handle.write(b"cover")
self.cover_art_paths.append(path)
return path
def verifyImage(self, _runner, _itable):
return None
def writeCue(self, _disc_name):
self.write_cue = True
def write_m3u(self, _disc_name):
self.write_m3u_called = True
def writeLog(self, _disc_name, _logger):
self.write_log_called = True
def fake_rip_track(_self, _runner, program, _itable, _settings, track_number, _item_index, _item_total,
_cover_art_path, _skipped_tracks, _mbdiscid):
track_result = SimpleNamespace(number=track_number, filename="track.flac")
program.result.tracks.append(track_result)
runner = FakeRunner()
monkeypatch.setattr(gui, "Program", FakeProgram)
monkeypatch.setattr(gui, "CancellableSyncRunner", lambda: runner)
monkeypatch.setattr(gui.config, "Config", lambda: SimpleNamespace(
getDefeatsCache=lambda *_args: True,
))
monkeypatch.setattr(gui.utils, "load_device", lambda _device: None)
monkeypatch.setattr(gui.utils, "unmount_device", lambda _device: None)
monkeypatch.setattr(gui.drive, "get_cdrom_drive_status", lambda _device: 0)
monkeypatch.setattr(gui.drive, "getDeviceInfo", lambda _device: ("vendor", "model", "release"))
monkeypatch.setattr(gui.cdrdao, "DetectCdr", lambda _device: False)
monkeypatch.setattr(gui.cdrdao, "version", lambda: "1.0")
monkeypatch.setattr(gui.cdparanoia, "getCdParanoiaVersion", lambda: "10.2")
monkeypatch.setattr(gui.cdio, "Device", lambda _device: SimpleNamespace(
get_hwinfo=lambda: (None, "vendor", "model", "release")
))
monkeypatch.setattr(gui.importlib.util, "find_spec", lambda name: object() if name == "PIL" else None)
monkeypatch.setattr(gui.result, "getLoggers", lambda: {"whipper": lambda: object()})
monkeypatch.setattr(gui.accurip, "print_report", lambda _result: None)
monkeypatch.setattr(gui.GLib, "idle_add", lambda func, *args: func(*args))
app._rip_track = MethodType(fake_rip_track, app)
settings = {
"device": "/dev/cdrom",
"output_directory": str(tmp_path),
"working_directory": None,
"country": None,
"release_id": None,
"unknown": False,
"cdr": False,
"keep_going": True,
"overread": False,
"cover_art": "complete",
"max_retries": 1,
"offset": 0,
"logger": "whipper",
"track_template": "%t",
"disc_template": "%d",
}
gui.WhipperGui._rip_disc_worker(app, settings)
program = program_holder["program"]
assert program.write_cue is True
assert program.write_m3u_called is True
assert program.write_log_called is True
assert program.cover_art_paths
assert app.status_label.get_text() == "Done"
assert "Rip finished successfully" in "".join(app.logs)
def test_rip_disc_worker_cancel_during_verify(monkeypatch, tmp_path):
app = _make_ui_app(tmp_path)
app.current_release = _make_metadata("Selected", 200000, "release-mbid")
app.unknown_check.set_active(False)
app.scan_data = {"mbid": "mbid-disc", "releases": [app.current_release]}
verify_started = threading.Event()
class FakeProgram:
def __init__(self, _conf, record=False):
assert record is False
self.metadata = None
self.outdir = None
self.result = FakeRipResult()
def getFastToc(self, _runner, _device):
return SimpleNamespace(
getCDDBDiscId=lambda: "cddb-id",
getMusicBrainzDiscId=lambda: "mbid-disc",
)
def getRipResult(self):
return self.result
def getPath(self, base, _template, _mbdiscid, _metadata, track_number=None):
if track_number is None:
return os.path.join(base, "Artist - Selected", "Artist - Selected")
return os.path.join(base, "Artist - Selected", "track-%02d" % track_number)
def getTable(self, *_args):
return FakeITable()
def getHTOA(self):
return None
def verifyImage(self, _runner, _itable):
verify_started.set()
while not app.rip_cancel_requested:
time.sleep(0.01)
raise gui.RipCancelledError("Rip cancelled")
def writeCue(self, _disc_name):
return None
def write_m3u(self, _disc_name):
return None
def writeLog(self, _disc_name, _logger):
return None
def fake_rip_track(_self, _runner, program, _itable, _settings, track_number, _item_index, _item_total,
_cover_art_path, _skipped_tracks, _mbdiscid):
program.result.tracks.append(SimpleNamespace(number=track_number, filename="track.flac"))
runner = FakeRunner()
monkeypatch.setattr(gui, "Program", FakeProgram)
monkeypatch.setattr(gui, "CancellableSyncRunner", lambda: runner)
monkeypatch.setattr(gui.config, "Config", lambda: SimpleNamespace(
getDefeatsCache=lambda *_args: True,
))
monkeypatch.setattr(gui.utils, "load_device", lambda _device: None)
monkeypatch.setattr(gui.utils, "unmount_device", lambda _device: None)
monkeypatch.setattr(gui.drive, "get_cdrom_drive_status", lambda _device: 0)
monkeypatch.setattr(gui.drive, "getDeviceInfo", lambda _device: ("vendor", "model", "release"))
monkeypatch.setattr(gui.cdrdao, "DetectCdr", lambda _device: False)
monkeypatch.setattr(gui.cdrdao, "version", lambda: "1.0")
monkeypatch.setattr(gui.cdparanoia, "getCdParanoiaVersion", lambda: "10.2")
monkeypatch.setattr(gui.cdio, "Device", lambda _device: SimpleNamespace(
get_hwinfo=lambda: (None, "vendor", "model", "release")
))
monkeypatch.setattr(gui.importlib.util, "find_spec", lambda _name: None)
monkeypatch.setattr(gui.result, "getLoggers", lambda: {"whipper": lambda: object()})
monkeypatch.setattr(gui.accurip, "print_report", lambda _result: None)
monkeypatch.setattr(gui.GLib, "timeout_add", lambda *_args: 1)
monkeypatch.setattr(gui.GLib, "source_remove", lambda *_args: None)
app._rip_track = MethodType(fake_rip_track, app)
settings = {
"device": "/dev/cdrom",
"output_directory": str(tmp_path),
"working_directory": None,
"country": None,
"release_id": None,
"unknown": False,
"cdr": False,
"keep_going": True,
"overread": False,
"cover_art": None,
"max_retries": 1,
"offset": 0,
"logger": "whipper",
"track_template": "%t",
"disc_template": "%d",
}
worker = threading.Thread(
target=gui.WhipperGui._rip_disc_worker,
args=(app, settings),
)
worker.start()
assert verify_started.wait(timeout=2.0)
gui.WhipperGui._on_stop_clicked(app, None)
_drain_glib_until(lambda: not worker.is_alive())
worker.join()
assert app.rip_runner is None
assert app.rip_cancel_requested is True
assert runner.cancelled is True
assert app.status_label.get_text() == "Cancelled"

View File

@@ -2,6 +2,7 @@
# vi:si:et:sw=4:sts=4:ts=4
import os
from unittest import mock
from whipper.extern.task import task
@@ -89,3 +90,59 @@ class CacheTestCase(common.TestCase):
t = AnalyzeFileTask(path)
self.runner.run(t)
self.assertTrue(t.defeatsCache)
class ReadTrackAbortTestCase(common.TestCase):
def testAbortTerminatesRunningProcess(self):
popen = mock.Mock()
popen.poll.return_value = None
rip_task = cdparanoia.ReadTrackTask(
'/tmp/track.wav', mock.Mock(), 0, 0, False
)
rip_task._popen = popen
rip_task.abort()
popen.terminate.assert_called_once_with()
def testAbortIgnoresFinishedProcess(self):
popen = mock.Mock()
popen.poll.return_value = 0
rip_task = cdparanoia.ReadTrackTask(
'/tmp/track.wav', mock.Mock(), 0, 0, False
)
rip_task._popen = popen
rip_task.abort()
popen.terminate.assert_not_called()
class ReadVerifyAbortTestCase(common.TestCase):
def testAbortDelegatesToCurrentTaskAbort(self):
current_task = mock.Mock()
current_task.abort = mock.Mock()
verify_task = cdparanoia.ReadVerifyTrackTask.__new__(
cdparanoia.ReadVerifyTrackTask
)
verify_task.tasks = [mock.Mock(), current_task]
verify_task._task = 2
verify_task.abort()
current_task.abort.assert_called_once_with()
def testAbortFallsBackToStop(self):
current_task = mock.Mock()
del current_task.abort
verify_task = cdparanoia.ReadVerifyTrackTask.__new__(
cdparanoia.ReadVerifyTrackTask
)
verify_task.tasks = [current_task]
verify_task._task = 1
verify_task.abort()
current_task.stop.assert_called_once_with()