From f6af198d4a1cec9a96ee46493757a865f474c33c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 2 Sep 2024 19:51:23 +0530 Subject: [PATCH] GUI to provide feedback while downloading voices --- src/calibre/gui2/tts2/download.py | 181 ++++++++++++++++++++++++++++++ src/calibre/gui2/tts2/piper.py | 7 +- 2 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 src/calibre/gui2/tts2/download.py diff --git a/src/calibre/gui2/tts2/download.py b/src/calibre/gui2/tts2/download.py new file mode 100644 index 0000000000..10648165bf --- /dev/null +++ b/src/calibre/gui2/tts2/download.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2024, Kovid Goyal + +import os +import tempfile +from contextlib import suppress + +from qt.core import ( + QDialog, + QDialogButtonBox, + QFileInfo, + QLabel, + QNetworkAccessManager, + QNetworkReply, + QNetworkRequest, + QProgressBar, + QScrollArea, + Qt, + QTimeZone, + QUrl, + QVBoxLayout, + QWidget, + pyqtSignal, +) + +from calibre import human_readable +from calibre.gui2 import error_dialog +from calibre.utils.localization import ngettext + + +class ProgressBar(QWidget): + + done = pyqtSignal(str) + + def __init__(self, qurl: QUrl, path: str, nam: QNetworkAccessManager, text: str, parent: QWidget | None): + super().__init__(parent) + self.l = l = QVBoxLayout(self) + self.la = la = QLabel(text) + la.setWordWrap(True) + l.addWidget(la) + self.pb = pb = QProgressBar(self) + pb.setTextVisible(True) + pb.setMinimum(0), pb.setMaximum(0) + l.addWidget(pb) + self.qurl = qurl + self.desc = text + self.path = path + self.file_obj = tempfile.NamedTemporaryFile('wb', dir=os.path.dirname(self.path), delete=False) + req = QNetworkRequest(qurl) + fi = QFileInfo(self.path) + if fi.exists(): + req.setHeader(QNetworkRequest.KnownHeaders.IfModifiedSinceHeader, fi.lastModified(QTimeZone(QTimeZone.Initialization.UTC))) + + self.reply = reply = nam.get(req) + self.over_reported = False + reply.downloadProgress.connect(self.on_download) + reply.errorOccurred.connect(self.on_error) + reply.finished.connect(self.finished) + reply.readyRead.connect(self.data_received) + + def data_received(self): + try: + self.file_obj.write(self.reply.readAll()) + except Exception as e: + self.on_over(_('Failed to write downloaded data with error: {}').format(e)) + + def on_error(self, ec: QNetworkReply.NetworkError) -> None: + self.on_over(_('Failed to write downloaded data with error: {}').format(self.reply.errorString())) + + def on_over(self, err_msg: str = '') -> None: + if self.over_reported: + return + self.over_reported = True + with suppress(Exception): + self.file_obj.close() + if err_msg: + with suppress(OSError): + os.remove(self.file_obj.name) + else: + code = self.reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) + if code == 200: + os.replace(self.file_obj.name, self.path) + else: + with suppress(OSError): + os.remove(self.file_obj.name) + if code != 304: # 304 is Not modified + err_msg = _('Server replied with unknown HTTP status code: {}').format(code) + self.done.emit(err_msg) + + def on_download(self, received: int, total: int) -> None: + if total > 0: + self.pb.setMaximum(total) + self.pb.setValue(received) + t = human_readable(total) + r = human_readable(received) + self.pb.setFormat(f'%p% {r} of {t}') + + def finished(self): + self.pb.setMaximum(100) + self.pb.setValue(100) + self.pb.setFormat(_('Download finished')) + self.on_over() + + +class DownloadResources(QDialog): + + def __init__(self, title: str, message: str, urls: dict[str, tuple[str, str]], parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle(title) + self.l = l = QVBoxLayout(self) + self.la = la = QLabel(message) + la.setWordWrap(True) + l.addWidget(la) + self.scroll_area = sa = QScrollArea(self) + sa.setWidgetResizable(True) + l.addWidget(sa) + self.central = central = QWidget(sa) + central.l = QVBoxLayout(central) + sa.setWidget(central) + + self.todo = set() + self.bars = [] + self.failures = [] + self.nam = nam = QNetworkAccessManager(self) + for url, (path, desc) in urls.items(): + qurl = QUrl(url) + self.todo.add(qurl) + pb = ProgressBar(qurl, path, nam, desc, self) + pb.done.connect(self.on_done, type=Qt.ConnectionType.QueuedConnection) + central.l.addWidget(pb) + self.bars.append(pb) + + self.bb = bb = QDialogButtonBox(QDialogButtonBox.Cancel, self) + bb.rejected.connect(self.reject) + l.addWidget(bb) + sz = self.sizeHint() + sz.setWidth(max(500, sz.width())) + self.resize(sz) + + def on_done(self, err_msg: str): + pb = self.sender() + self.todo.discard(pb.qurl) + if err_msg: + self.failures.append(_('Failed to download {0} with error: {1}').format(pb.desc, err_msg)) + if not self.todo: + if self.failures: + if len(self.failures) == len(self.bars): + msg = ngettext(_('Could not download {}.'), _('Could not download all resources.'), len(self.bars)).format(self.bars[0].desc) + else: + msg = _('Could not download some resources.') + error_dialog( + self, _('Download failed'), msg + ' ' + _('Click "Show details" for more information'), + det_msg='\n\n'.join(self.failures), show=True) + self.reject() + else: + self.accept() + + def reject(self): + for pb in self.bars: + pb.blockSignals(True) + pb.reply.abort() + super().reject() + + +def develop(): + from calibre.gui2 import Application + app = Application([]) + urls = { + 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx': ( + '/tmp/model', 'Voice neural network'), + 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json': ( + '/tmp/config', 'Voice configuration'), + } + d = DownloadResources('Test download resources', 'Downloading voice data', urls) + d.exec() + del d + del app + + +if __name__ == '__main__': + develop() diff --git a/src/calibre/gui2/tts2/piper.py b/src/calibre/gui2/tts2/piper.py index f32e1ba69c..deae62fef2 100644 --- a/src/calibre/gui2/tts2/piper.py +++ b/src/calibre/gui2/tts2/piper.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from itertools import count from time import monotonic -from qt.core import QApplication, QAudio, QAudioFormat, QAudioSink, QByteArray, QIODevice, QIODeviceBase, QObject, QProcess, Qt, QTextToSpeech, pyqtSignal, sip +from qt.core import QAudio, QAudioFormat, QAudioSink, QByteArray, QIODevice, QIODeviceBase, QObject, QProcess, Qt, QTextToSpeech, pyqtSignal, sip from calibre.constants import is_debugging from calibre.gui2.tts2.types import Quality, TTSBackend, Voice, piper_cmdline @@ -389,9 +389,8 @@ def develop(): # {{{ from qt.core import QSocketNotifier - from calibre.gui2 import must_use_qt - must_use_qt() - app = QApplication.instance() + from calibre.gui2 import Application + app = Application([]) p = Piper() play_started = False def state_changed(s):