Bulk metadata edit: Add a new control to compress the cover image files for all selected books. Fixes #1911888 [Editing an ePub's internal cover should update its metadata cover](https://bugs.launchpad.net/calibre/+bug/1911888)

This commit is contained in:
Kovid Goyal 2021-01-20 12:32:19 +05:30
parent f64dfddd40
commit 1c12b0efe6
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 671 additions and 512 deletions

View File

@ -1474,6 +1474,21 @@ class DB(object):
with f:
return True, f.read(), stat.st_mtime
def compress_covers(self, path_map, jpeg_quality, progress_callback):
cpath_map = {}
if not progress_callback:
progress_callback = lambda book_id, old_sz, new_sz: None
for book_id, path in path_map.items():
path = os.path.abspath(os.path.join(self.library_path, path, 'cover.jpg'))
try:
sz = os.path.getsize(path)
except OSError:
progress_callback(book_id, 0, 'ENOENT')
else:
cpath_map[book_id] = (path, sz)
from calibre.db.covers import compress_covers
compress_covers(cpath_map, jpeg_quality, progress_callback)
def set_cover(self, book_id, path, data, no_processing=False):
path = os.path.abspath(os.path.join(self.library_path, path))
if not os.path.exists(path):

View File

@ -763,6 +763,25 @@ class Cache(object):
return self.backend.copy_cover_to(path, dest, use_hardlink=use_hardlink,
report_file_size=report_file_size)
@write_api
def compress_covers(self, book_ids, jpeg_quality=100, progress_callback=None):
'''
Compress the cover images for the specified books. A compression quality of 100
will perform lossless compression, otherwise lossy compression.
The progress callback will be called with the book_id and the old and new sizes
for each book that has been processed. If an error occurs, the news size will
be a string with th error details.
'''
jpeg_quality = max(10, min(jpeg_quality, 100))
path_map = {}
for book_id in book_ids:
try:
path_map[book_id] = self._field_for('path', book_id).replace('/', os.sep)
except AttributeError:
continue
self.backend.compress_covers(path_map, jpeg_quality, progress_callback)
@read_api
def copy_format_to(self, book_id, fmt, dest, use_hardlink=False, report_file_size=None):
'''

58
src/calibre/db/covers.py Normal file
View File

@ -0,0 +1,58 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
import os
from queue import Queue
from threading import Thread
from calibre import detect_ncpus
from calibre.utils.img import encode_jpeg, optimize_jpeg
def worker(input_queue, output_queue, jpeg_quality):
while True:
task = input_queue.get()
if task is None:
break
book_id, path = task
try:
if jpeg_quality >= 100:
stderr = optimize_jpeg(path)
else:
stderr = encode_jpeg(path, jpeg_quality)
except Exception:
import traceback
stderr = traceback.format_exc()
if stderr:
output_queue.put((book_id, stderr))
else:
try:
sz = os.path.getsize(path)
except OSError as err:
sz = str(err)
output_queue.put((book_id, sz))
def compress_covers(path_map, jpeg_quality, progress_callback):
input_queue = Queue()
output_queue = Queue()
num_workers = detect_ncpus()
sz_map = {}
for book_id, (path, sz) in path_map.items():
input_queue.put((book_id, path))
sz_map[book_id] = sz
workers = [
Thread(target=worker, args=(input_queue, output_queue, jpeg_quality), daemon=True, name=f'CCover-{i}')
for i in range(num_workers)
]
[w.start() for w in workers]
pending = set(path_map)
while pending:
book_id, new_sz = output_queue.get()
pending.remove(book_id)
progress_callback(book_id, sz_map[book_id], new_sz)
for w in workers:
input_queue.put(None)
for w in workers:
w.join()

View File

@ -3,34 +3,36 @@
# License: GPLv3 Copyright: 2008, Kovid Goyal <kovid at kovidgoyal.net>
import regex, numbers
import numbers
import regex
from collections import defaultdict, namedtuple
from io import BytesIO
from PyQt5.Qt import (
QApplication, QComboBox, QCompleter, QCoreApplication, QDateTime, QDialog,
QDialogButtonBox, QFont, QGridLayout, QInputDialog, QLabel, QLineEdit,
QProgressBar, QSize, Qt, QVBoxLayout, pyqtSignal
)
from threading import Thread
from PyQt5.Qt import (
QCompleter, QCoreApplication, QDateTime, QDialog, QDialogButtonBox, QFont, QProgressBar, QComboBox,
QGridLayout, QInputDialog, QLabel, QLineEdit, QSize, Qt, QVBoxLayout, pyqtSignal, QApplication
)
from calibre import prints
from calibre import human_readable, prints
from calibre.constants import DEBUG
from calibre.db import _get_next_series_num_for_list
from calibre.ebooks.metadata import authors_to_string, string_to_authors, title_sort
from calibre.ebooks.metadata.book.formatter import SafeFormat
from calibre.ebooks.metadata.opf2 import OPF
from calibre.gui2 import (
UNDEFINED_QDATETIME, FunctionDispatcher, error_dialog, gprefs, question_dialog
UNDEFINED_QDATETIME, FunctionDispatcher, error_dialog, gprefs, info_dialog,
question_dialog
)
from calibre.gui2.custom_column_widgets import populate_metadata_page
from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog
from calibre.gui2.dialogs.tag_editor import TagEditor
from calibre.gui2.dialogs.template_line_editor import TemplateLineEditor
from calibre.gui2.widgets import LineEditECM
from calibre.utils.config import JSONConfig, dynamic, prefs, tweaks
from calibre.utils.date import qt_to_dt, internal_iso_format_string
from calibre.utils.date import internal_iso_format_string, qt_to_dt
from calibre.utils.icu import capitalize, sort_key
from calibre.utils.titlecase import titlecase
from calibre.gui2.widgets import LineEditECM
from polyglot.builtins import (
error_message, filter, iteritems, itervalues, native_string_type, unicode_type
)
@ -39,7 +41,7 @@ Settings = namedtuple('Settings',
'remove_all remove add au aus do_aus rating pub do_series do_autonumber '
'do_swap_ta do_remove_conv do_auto_author series do_series_restart series_start_value series_increment '
'do_title_case cover_action clear_series clear_pub pubdate adddate do_title_sort languages clear_languages '
'restore_original comments generate_cover_settings read_file_metadata casing_algorithm')
'restore_original comments generate_cover_settings read_file_metadata casing_algorithm do_compress_cover compress_cover_quality')
null = object()
@ -68,6 +70,7 @@ class MyBlockingBusy(QDialog): # {{{
self._layout = l = QVBoxLayout()
self.setLayout(l)
self.cover_sizes = {'old': 0, 'new': 0}
# Every Path that will be taken in do_all
options = [
args.cover_action == 'fromfmt' or args.read_file_metadata,
@ -82,7 +85,7 @@ class MyBlockingBusy(QDialog): # {{{
is not None, args.do_series, bool(args.series) and
args.do_autonumber, args.comments is not null,
args.do_remove_conv, args.clear_languages, args.remove_all,
bool(do_sr)
bool(do_sr), args.do_compress_cover
]
self.selected_options = sum(options)
if DEBUG:
@ -312,7 +315,9 @@ class MyBlockingBusy(QDialog): # {{{
elif args.cover_action == 'trim':
self.progress_next_step_range.emit(len(self.ids))
from calibre.utils.img import remove_borders_from_image, image_to_data, image_from_data
from calibre.utils.img import (
image_from_data, image_to_data, remove_borders_from_image
)
for book_id in self.ids:
cdata = cache.cover(book_id)
if cdata:
@ -437,6 +442,18 @@ class MyBlockingBusy(QDialog): # {{{
self.db.bulk_modify_tags(self.ids, add=args.add, remove=args.remove)
self.progress_finished_cur_step.emit()
if args.do_compress_cover:
self.progress_next_step_range.emit(len(self.ids))
def pc(book_id, old_sz, new_sz):
if isinstance(new_sz, int):
self.cover_sizes['old'] += old_sz
self.cover_sizes['new'] += new_sz
self.progress_update.emit(1)
self.db.new_api.compress_covers(self.ids, args.compress_cover_quality, pc)
self.progress_finished_cur_step.emit()
if self.do_sr:
self.progress_next_step_range.emit(len(self.ids))
for book_id in self.ids:
@ -1182,6 +1199,8 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
do_auto_author = self.auto_author_sort.isChecked()
do_title_case = self.change_title_to_title_case.isChecked()
do_title_sort = self.update_title_sort.isChecked()
do_compress_cover = self.compress_cover_images.isChecked()
compress_cover_quality = self.compress_quality.value()
read_file_metadata = self.read_file_metadata.isChecked()
clear_languages = self.clear_languages.isChecked()
restore_original = self.restore_original.isChecked()
@ -1211,7 +1230,7 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
do_title_case, cover_action, clear_series, clear_pub, pubdate,
adddate, do_title_sort, languages, clear_languages,
restore_original, self.comments, self.generate_cover_settings,
read_file_metadata, self.casing_map[self.casing_algorithm.currentIndex()])
read_file_metadata, self.casing_map[self.casing_algorithm.currentIndex()], do_compress_cover, compress_cover_quality)
if DEBUG:
print('Running bulk metadata operation with settings:')
print(args)
@ -1239,6 +1258,14 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog):
dynamic['s_r_search_mode'] = self.search_mode.currentIndex()
gprefs.set('bulk-mde-casing-algorithm', args.casing_algorithm)
self.db.clean()
if args.do_compress_cover:
total_old, total_new = bb.cover_sizes['old'], bb.cover_sizes['new']
percent = (total_old - total_new) / total_old
info_dialog(self, _('Covers compressed'), _(
'Covers were compressed by {percent:.1%} from a total size of'
' {old} to {new}.').format(
percent=percent, old=human_readable(total_old), new=human_readable(total_new))
).exec_()
return QDialog.accept(self)
def series_changed(self, *args):

File diff suppressed because it is too large Load Diff