From 9859812ec935c57a31d9a3a98c70bf0159295fe9 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 12 Aug 2013 13:40:36 +0530 Subject: [PATCH] Cover grid: Add a disk cache for rendered thumbnails --- src/calibre/db/backend.py | 16 + src/calibre/db/cache.py | 15 +- src/calibre/db/tests/utils.py | 83 +++++ src/calibre/db/utils.py | 297 +++++++++++++++ src/calibre/gui2/__init__.py | 1 + src/calibre/gui2/library/alternate_views.py | 94 ++++- src/calibre/gui2/library/caches.py | 15 +- src/calibre/gui2/preferences/look_feel.py | 41 ++- src/calibre/gui2/preferences/look_feel.ui | 377 ++++++++++++-------- 9 files changed, 768 insertions(+), 171 deletions(-) create mode 100644 src/calibre/db/tests/utils.py create mode 100644 src/calibre/db/utils.py diff --git a/src/calibre/db/backend.py b/src/calibre/db/backend.py index 871d704ce1..f977bbff49 100644 --- a/src/calibre/db/backend.py +++ b/src/calibre/db/backend.py @@ -1225,6 +1225,22 @@ class DB(object): return True return False + def cover_or_cache(self, path, timestamp): + path = os.path.abspath(os.path.join(self.library_path, path, 'cover.jpg')) + try: + stat = os.stat(path) + except EnvironmentError: + return False, None, None + if abs(timestamp - stat.st_mtime) < 0.1: + return True, None, None + try: + f = lopen(path, 'rb') + except (IOError, OSError): + time.sleep(0.2) + f = lopen(path, 'rb') + with f: + return True, f.read(), stat.st_mtime + def set_cover(self, book_id, path, data): path = os.path.abspath(os.path.join(self.library_path, path)) if not os.path.exists(path): diff --git a/src/calibre/db/cache.py b/src/calibre/db/cache.py index 0b58c23c1d..2dc5d557f7 100644 --- a/src/calibre/db/cache.py +++ b/src/calibre/db/cache.py @@ -587,6 +587,14 @@ class Cache(object): ret = i return ret + @read_api + def cover_or_cache(self, book_id, timestamp): + try: + path = self._field_for('path', book_id).replace('/', os.sep) + except AttributeError: + return False, None, None + return self.backend.cover_or_cache(path, timestamp) + @read_api def cover_last_modified(self, book_id): try: @@ -1032,8 +1040,8 @@ class Cache(object): path = self._field_for('path', book_id).replace('/', os.sep) self.backend.set_cover(book_id, path, data) - for cc in self.cover_caches: - cc.invalidate(book_id) + for cc in self.cover_caches: + cc.invalidate(book_id_data_map) return self._set_field('cover', { book_id:(0 if data is None else 1) for book_id, data in book_id_data_map.iteritems()}) @@ -1352,8 +1360,7 @@ class Cache(object): self._search_api.discard_books(book_ids) self._clear_caches(book_ids=book_ids, template_cache=False, search_cache=False) for cc in self.cover_caches: - for book_id in book_ids: - cc.invalidate(book_id) + cc.invalidate(book_ids) @read_api def author_sort_strings_for_books(self, book_ids): diff --git a/src/calibre/db/tests/utils.py b/src/calibre/db/tests/utils.py new file mode 100644 index 0000000000..c30264b6bb --- /dev/null +++ b/src/calibre/db/tests/utils.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Kovid Goyal ' + +import shutil + +from calibre import walk +from calibre.db.tests.base import BaseTest +from calibre.db.utils import ThumbnailCache + +class UtilsTest(BaseTest): + + def setUp(self): + self.tdir = self.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tdir) + + def init_tc(self, name='1', max_size=1): + return ThumbnailCache(name=name, location=self.tdir, max_size=max_size, test_mode=True) + + def basic_fill(self, c, num=5): + total = 0 + for i in range(1, num+1): + sz = i * 1000 + c.insert(i, i, (('%d'%i) * sz).encode('ascii')) + total += sz + return total + + def test_thumbnail_cache(self): # {{{ + ' Test the operation of the thumbnail cache ' + c = self.init_tc() + self.assertFalse(hasattr(c, 'total_size'), 'index read on initialization') + c.invalidate(666) + self.assertFalse(hasattr(c, 'total_size'), 'index read on invalidate') + + self.assertEqual(self.basic_fill(c), c.total_size) + self.assertEqual(5, len(c)) + + for i in (3, 4, 2, 5, 1): + data, ts = c[i] + self.assertEqual(i, ts, 'timestamp not correct') + self.assertEqual((('%d'%i) * (i*1000)).encode('ascii'), data) + c.set_group_id('a') + self.basic_fill(c) + order = tuple(c.items) + ts = c.current_size + c.shutdown() + c = self.init_tc() + self.assertEqual(c.current_size, ts, 'size not preserved after restart') + self.assertEqual(order, tuple(c.items), 'order not preserved after restart') + c.shutdown() + c = self.init_tc() + c.invalidate((1,)) + self.assertIsNone(c[1][1], 'invalidate before load_index() failed') + c.invalidate((2,)) + self.assertIsNone(c[2][1], 'invalidate after load_index() failed') + c.set_group_id('a') + c[1] + c.set_size(0.001) + self.assertLessEqual(c.current_size, 1024, 'set_size() failed') + self.assertEqual(len(c), 1) + self.assertIn(1, c) + c.insert(9, 9, b'x' * (c.max_size-1)) + self.assertEqual(len(c), 1) + self.assertLessEqual(c.current_size, c.max_size, 'insert() did not prune') + self.assertIn(9, c) + c.empty() + self.assertEqual(c.total_size, 0) + self.assertEqual(len(c), 0) + self.assertEqual(tuple(walk(c.location)), ()) + c = self.init_tc() + self.basic_fill(c) + self.assertEqual(len(c), 5) + c.set_thumbnail_size(200, 201) + self.assertIsNone(c[1][0]) + self.assertEqual(len(c), 0) + self.assertEqual(tuple(walk(c.location)), ()) + # }}} diff --git a/src/calibre/db/utils.py b/src/calibre/db/utils.py new file mode 100644 index 0000000000..8f77b9204e --- /dev/null +++ b/src/calibre/db/utils.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2013, Kovid Goyal ' + +import os, errno, cPickle, sys +from collections import OrderedDict, namedtuple +from future_builtins import map +from threading import Lock + +from calibre import as_unicode, prints +from calibre.constants import cache_dir + +Entry = namedtuple('Entry', 'path size timestamp thumbnail_size') +class CacheError(Exception): + pass + +class ThumbnailCache(object): + + ' This is a persistent disk cache to speed up loading and resizing of covers ' + + def __init__(self, + max_size=1024, # The maximum disk space in MB + name='thumbnail-cache', # The name of this cache (should be unique in location) + thumbnail_size=(100, 100), # The size of the thumbnails, can be changed + location=None, # The location for this cache, if None cache_dir() is used + test_mode=False, # Used for testing + min_disk_cache=0): # If the size is set less than or equal to this value, the cache is disabled. + self.location = os.path.join(location or cache_dir(), name) + if max_size <= min_disk_cache: + max_size = 0 + self.max_size = int(max_size * (1024**2)) + self.group_id = 'group' + self.thumbnail_size = thumbnail_size + self.size_changed = False + self.lock = Lock() + self.min_disk_cache = min_disk_cache + if test_mode: + self.log = self.fail_on_error + + def log(self, *args, **kwargs): + kwargs['file'] = sys.stderr + prints(*args, **kwargs) + + def fail_on_error(self, *args, **kwargs): + msg = ' '.join(args) + raise CacheError(msg) + + def _do_delete(self, path): + try: + os.remove(path) + except EnvironmentError as err: + self.log('Failed to delete cached thumbnail file:', as_unicode(err)) + + def _load_index(self): + 'Load the index, automatically removing incorrectly sized thumbnails and pruning to fit max_size' + try: + os.makedirs(self.location) + except OSError as err: + if err.errno != errno.EEXIST: + self.log('Failed to make thumbnail cache dir:', as_unicode(err)) + self.total_size = 0 + self.items = OrderedDict() + order = self._read_order() + def listdir(*args): + try: + return os.listdir(os.path.join(*args)) + except EnvironmentError: + return () # not a directory or no permission or whatever + entries = ('/'.join((parent, subdir, entry)) + for parent in listdir(self.location) + for subdir in listdir(self.location, parent) + for entry in listdir(self.location, parent, subdir)) + + invalidate = set() + try: + with open(os.path.join(self.location, 'invalidate'), 'rb') as f: + raw = f.read() + except EnvironmentError as err: + if getattr(err, 'errno', None) != errno.ENOENT: + self.log('Failed to read thumbnail invalidate data:', as_unicode(err)) + else: + try: + os.remove(os.path.join(self.location, 'invalidate')) + except EnvironmentError as err: + self.log('Failed to remove thumbnail invalidate data:', as_unicode(err)) + else: + def record(line): + try: + uuid, book_id = line.partition(' ')[0::2] + book_id = int(book_id) + return (uuid, book_id) + except Exception: + return None + invalidate = {record(x) for x in raw.splitlines()} + items = [] + try: + for entry in entries: + try: + uuid, name = entry.split('/')[0::2] + book_id, timestamp, size, thumbnail_size = name.split('-') + book_id, timestamp, size = int(book_id), float(timestamp), int(size) + thumbnail_size = tuple(map(int, thumbnail_size.partition('x')[0::2])) + except (ValueError, TypeError, IndexError, KeyError, AttributeError): + continue + key = (uuid, book_id) + path = os.path.join(self.location, entry) + if self.thumbnail_size == thumbnail_size and key not in invalidate: + items.append((key, Entry(path, size, timestamp, thumbnail_size))) + self.total_size += size + else: + self._do_delete(path) + except EnvironmentError as err: + self.log('Failed to read thumbnail cache dir:', as_unicode(err)) + + self.items = OrderedDict(sorted(items, key=lambda x:order.get(hash(x[0]), 0))) + self._apply_size() + + def _invalidate_sizes(self): + if self.size_changed: + size = self.thumbnail_size + remove = (key for key, entry in self.items.iteritems() if size != entry.thumbnail_size) + for key in remove: + self._remove(key) + self.size_changed = False + + def _remove(self, key): + entry = self.items.pop(key, None) + if entry is not None: + self._do_delete(entry.path) + self.total_size -= entry.size + + def _apply_size(self): + while self.total_size > self.max_size and self.items: + entry = self.items.popitem(last=False)[1] + self._do_delete(entry.path) + self.total_size -= entry.size + + def _write_order(self): + if hasattr(self, 'items'): + try: + with open(os.path.join(self.location, 'order'), 'wb') as f: + f.write(cPickle.dumps(tuple(map(hash, self.items)), -1)) + except EnvironmentError as err: + self.log('Failed to save thumbnail cache order:', as_unicode(err)) + + def _read_order(self): + order = {} + try: + with open(os.path.join(self.location, 'order'), 'rb') as f: + order = cPickle.loads(f.read()) + order = {k:i for i, k in enumerate(order)} + except Exception as err: + if getattr(err, 'errno', None) != errno.ENOENT: + self.log('Failed to load thumbnail cache order:', as_unicode(err)) + return order + + def shutdown(self): + with self.lock: + self._write_order() + + def set_group_id(self, group_id): + with self.lock: + self.group_id = group_id + + def set_thumbnail_size(self, width, height): + with self.lock: + self.thumbnail_size = (width, height) + self.size_changed = True + + def insert(self, book_id, timestamp, data): + if self.max_size < len(data): + return + with self.lock: + if not hasattr(self, 'total_size'): + self._load_index() + self._invalidate_sizes() + ts = ('%.2f' % timestamp).replace('.00', '') + path = '%s%s%s%s%d-%s-%d-%dx%d' % ( + self.group_id, os.sep, book_id % 100, os.sep, + book_id, ts, len(data), self.thumbnail_size[0], self.thumbnail_size[1]) + path = os.path.join(self.location, path) + key = (self.group_id, book_id) + e = self.items.pop(key, None) + self.total_size -= getattr(e, 'size', 0) + try: + with open(path, 'wb') as f: + f.write(data) + except EnvironmentError as err: + d = os.path.dirname(path) + if not os.path.exists(d): + try: + os.makedirs(d) + with open(path, 'wb') as f: + f.write(data) + except EnvironmentError as err: + self.log('Failed to write cached thumbnail:', path, as_unicode(err)) + return self._apply_size() + else: + self.log('Failed to write cached thumbnail:', path, as_unicode(err)) + return self._apply_size() + self.items[key] = Entry(path, len(data), timestamp, self.thumbnail_size) + self.total_size += len(data) + self._apply_size() + + def __len__(self): + with self.lock: + try: + return len(self.items) + except AttributeError: + self._load_index() + return len(self.items) + + def __contains__(self, book_id): + with self.lock: + try: + return (self.group_id, book_id) in self.items + except AttributeError: + self._load_index() + return (self.group_id, book_id) in self.items + + def __getitem__(self, book_id): + with self.lock: + if not hasattr(self, 'total_size'): + self._load_index() + self._invalidate_sizes() + key = (self.group_id, book_id) + entry = self.items.pop(key, None) + if entry is None: + return None, None + if entry.thumbnail_size != self.thumbnail_size: + try: + os.remove(entry.path) + except EnvironmentError as err: + if getattr(err, 'errno', None) != errno.ENOENT: + self.log('Failed to remove cached thumbnail:', entry.path, as_unicode(err)) + self.total_size -= entry.size + return None, None + self.items[key] = entry + try: + with open(entry.path, 'rb') as f: + data = f.read() + except EnvironmentError as err: + self.log('Failed to read cached thumbnail:', entry.path, as_unicode(err)) + return None, None + return data, entry.timestamp + + def invalidate(self, book_ids): + with self.lock: + if hasattr(self, 'total_size'): + for book_id in book_ids: + self._remove((self.group_id, book_id)) + elif os.path.exists(self.location): + try: + raw = '\n'.join('%s %d' % (self.group_id, book_id) for book_id in book_ids) + with open(os.path.join(self.location, 'invalidate'), 'ab') as f: + f.write(raw.encode('ascii')) + except EnvironmentError as err: + self.log('Failed to write invalidate thumbnail record:', as_unicode(err)) + + @property + def current_size(self): + with self.lock: + if not hasattr(self, 'total_size'): + self._load_index() + return self.total_size + + def empty(self): + with self.lock: + try: + os.remove(os.path.join(self.location, 'order')) + except EnvironmentError: + pass + if not hasattr(self, 'total_size'): + self._load_index() + for entry in self.items.itervalues(): + self._do_delete(entry.path) + self.total_size = 0 + self.items = OrderedDict() + + def __hash__(self): + return id(self) + + def set_size(self, size_in_mb): + if size_in_mb <= self.min_disk_cache: + size_in_mb = 0 + size_in_mb = max(0, size_in_mb) + with self.lock: + self.max_size = int(size_in_mb * (1024**2)) + if hasattr(self, 'total_size'): + self._apply_size() + + + diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 7d0f85094b..50e434bb49 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -115,6 +115,7 @@ defs['cover_grid_width'] = 0 defs['cover_grid_height'] = 0 defs['cover_grid_color'] = (80, 80, 80) defs['cover_grid_cache_size'] = 200 +defs['cover_grid_disk_cache_size'] = 2000 defs['cover_grid_spacing'] = 0 defs['cover_grid_show_title'] = False del defs diff --git a/src/calibre/gui2/library/alternate_views.py b/src/calibre/gui2/library/alternate_views.py index cd62d38a3c..fe5117916a 100644 --- a/src/calibre/gui2/library/alternate_views.py +++ b/src/calibre/gui2/library/alternate_views.py @@ -19,14 +19,29 @@ from PyQt4.Qt import ( QTimer, QPalette, QColor, QItemSelection, QPixmap, QMenu, QApplication, QMimeData, QUrl, QDrag, QPoint, QPainter, QRect, pyqtProperty, QEvent, QPropertyAnimation, QEasingCurve, pyqtSlot, QHelpEvent, QAbstractItemView, - QStyleOptionViewItem, QToolTip) + QStyleOptionViewItem, QToolTip, QByteArray, QBuffer) -from calibre import fit_image +from calibre import fit_image, prints from calibre.gui2 import gprefs, config -from calibre.gui2.library.caches import CoverCache +from calibre.gui2.library.caches import CoverCache, ThumbnailCache from calibre.utils.config import prefs CM_TO_INCH = 0.393701 +CACHE_FORMAT = 'PPM' + +class EncodeError(ValueError): + pass + +def image_to_data(image): # {{{ + ba = QByteArray() + buf = QBuffer(ba) + buf.open(QBuffer.WriteOnly) + if not image.save(buf, CACHE_FORMAT): + raise EncodeError('Failed to encode thumbnail') + ret = bytes(ba.data()) + buf.close() + return ret +# }}} # Drag 'n Drop {{{ def dragMoveEvent(self, event): @@ -443,6 +458,8 @@ class GridView(QListView): self.setSpacing(self.delegate.spacing) self.set_color() self.ignore_render_requests = Event() + self.thumbnail_cache = ThumbnailCache(max_size=gprefs['cover_grid_disk_cache_size'], + thumbnail_size=(self.delegate.cover_size.width(), self.delegate.cover_size.height())) self.render_thread = None self.update_item.connect(self.re_render, type=Qt.QueuedConnection) self.doubleClicked.connect(self.double_clicked) @@ -485,12 +502,10 @@ class GridView(QListView): def slider_pressed(self): self.ignore_render_requests.set() self.verticalScrollBar().valueChanged.connect(self.value_changed_during_scroll) - self.update_timer.setInterval(500) def slider_released(self): self.update_viewport() self.verticalScrollBar().valueChanged.disconnect(self.value_changed_during_scroll) - self.update_timer.setInterval(200) def value_changed_during_scroll(self): if self.ignore_render_requests.is_set(): @@ -545,9 +560,15 @@ class GridView(QListView): self.setSpacing(self.delegate.spacing) self.set_color() self.delegate.cover_cache.set_limit(gprefs['cover_grid_cache_size']) + if size_changed: + self.thumbnail_cache.set_thumbnail_size(self.delegate.cover_size.width(), self.delegate.cover_size.height()) + cs = gprefs['cover_grid_disk_cache_size'] + if (cs*(1024**2)) != self.thumbnail_cache.max_size: + self.thumbnail_cache.set_size(cs) def shown(self): if self.render_thread is None: + self.thumbnail_cache.set_database(self.gui.current_db) self.render_thread = Thread(target=self.render_covers) self.render_thread.daemon = True self.render_thread.start() @@ -572,19 +593,51 @@ class GridView(QListView): def render_cover(self, book_id): if self.ignore_render_requests.is_set(): return - cdata = self.model().db.new_api.cover(book_id) - if cdata is not None: + tcdata, timestamp = self.thumbnail_cache[book_id] + use_cache = False + if timestamp is None: + # Not in cache + has_cover, cdata, timestamp = self.model().db.new_api.cover_or_cache(book_id, 0) + else: + has_cover, cdata, timestamp = self.model().db.new_api.cover_or_cache(book_id, timestamp) + if has_cover and cdata is None: + # The cached cover is fresh + cdata = tcdata + use_cache = True + + if has_cover: p = QImage() - p.loadFromData(cdata) - cdata = None - if not p.isNull(): - width, height = p.width(), p.height() - scaled, nwidth, nheight = fit_image(width, height, self.delegate.cover_size.width(), self.delegate.cover_size.height()) - if scaled: - if self.ignore_render_requests.is_set(): - return - p = p.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) - cdata = p + p.loadFromData(cdata, CACHE_FORMAT if cdata is tcdata else 'JPEG') + if p.isNull() and cdata is tcdata: + # Invalid image in cache + self.thumbnail_cache.invalidate((book_id,)) + self.update_item.emit(book_id) + return + cdata = None if p.isNull() else p + if not use_cache: # cache is stale + if cdata is not None: + width, height = p.width(), p.height() + scaled, nwidth, nheight = fit_image(width, height, self.delegate.cover_size.width(), self.delegate.cover_size.height()) + if scaled: + if self.ignore_render_requests.is_set(): + return + p = p.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) + cdata = p + # update cache + if cdata is None: + self.thumbnail_cache.invalidate((book_id,)) + else: + try: + self.thumbnail_cache.insert(book_id, timestamp, image_to_data(cdata)) + except EncodeError as err: + self.thumbnail_cache.invalidate((book_id,)) + prints(err) + except Exception: + import traceback + traceback.print_exc() + elif tcdata is not None: + # Cover was removed, but it exists in cache, remove from cache + self.thumbnail_cache.invalidate((book_id,)) self.delegate.cover_cache.set(book_id, cdata) self.update_item.emit(book_id) @@ -600,6 +653,7 @@ class GridView(QListView): def shutdown(self): self.ignore_render_requests.set() self.delegate.render_queue.put(None) + self.thumbnail_cache.shutdown() def set_database(self, newdb, stage=0): if not hasattr(newdb, 'new_api'): @@ -607,10 +661,12 @@ class GridView(QListView): if stage == 0: self.ignore_render_requests.set() try: - self.model().db.new_api.remove_cover_cache(self.delegate.cover_cache) + for x in (self.delegate.cover_cache, self.thumbnail_cache): + self.model().db.new_api.remove_cover_cache(x) except AttributeError: pass # db is None - newdb.new_api.add_cover_cache(self.delegate.cover_cache) + for x in (self.delegate.cover_cache, self.thumbnail_cache): + newdb.new_api.add_cover_cache(x) try: # Use a timeout so that if, for some reason, the render thread # gets stuck, we dont deadlock, future covers wont get diff --git a/src/calibre/gui2/library/caches.py b/src/calibre/gui2/library/caches.py index 45dc5cddd5..2788bcfefe 100644 --- a/src/calibre/gui2/library/caches.py +++ b/src/calibre/gui2/library/caches.py @@ -11,6 +11,15 @@ from collections import OrderedDict from PyQt4.Qt import QImage, QPixmap +from calibre.db.utils import ThumbnailCache as TC + +class ThumbnailCache(TC): + def __init__(self, max_size=1024, thumbnail_size=(100, 100)): + TC.__init__(self, name='gui-thumbnail-cache', min_disk_cache=100, max_size=max_size, thumbnail_size=thumbnail_size) + + def set_database(self, db): + TC.set_group_id(self, db.library_id) + class CoverCache(dict): ' This is a RAM cache to speed up rendering of covers by storing them as QPixmaps ' @@ -26,9 +35,10 @@ class CoverCache(dict): ' Must be called in the GUI thread ' self.pixmap_staging = [] - def invalidate(self, book_id): + def invalidate(self, book_ids): with self.lock: - self._pop(book_id) + for book_id in book_ids: + self._pop(book_id) def _pop(self, book_id): val = self.items.pop(book_id, None) @@ -75,3 +85,4 @@ class CoverCache(dict): for k in remove: self._pop(k) + diff --git a/src/calibre/gui2/preferences/look_feel.py b/src/calibre/gui2/preferences/look_feel.py index 552f907ab2..31f3807ced 100644 --- a/src/calibre/gui2/preferences/look_feel.py +++ b/src/calibre/gui2/preferences/look_feel.py @@ -5,12 +5,15 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' -from PyQt4.Qt import (QApplication, QFont, QFontInfo, QFontDialog, QColorDialog, - QAbstractListModel, Qt, QIcon, QKeySequence, QPalette, QColor) +from threading import Thread +from PyQt4.Qt import (QApplication, QFont, QFontInfo, QFontDialog, QColorDialog, + QAbstractListModel, Qt, QIcon, QKeySequence, QPalette, QColor, pyqtSignal) + +from calibre import human_readable from calibre.gui2.preferences import ConfigWidgetBase, test_widget, CommaSeparatedList from calibre.gui2.preferences.look_feel_ui import Ui_Form -from calibre.gui2 import config, gprefs, qt_app, NONE +from calibre.gui2 import config, gprefs, qt_app, NONE, open_local_file from calibre.utils.localization import (available_translations, get_language, get_lang) from calibre.utils.config import prefs, tweaks @@ -95,6 +98,8 @@ class DisplayedFields(QAbstractListModel): # {{{ class ConfigWidget(ConfigWidgetBase, Ui_Form): + size_calculated = pyqtSignal(object) + def genesis(self, gui): self.gui = gui db = gui.library_view.model().db @@ -113,6 +118,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): r('cover_grid_width', gprefs) r('cover_grid_height', gprefs) r('cover_grid_cache_size', gprefs) + r('cover_grid_disk_cache_size', gprefs) r('cover_grid_spacing', gprefs) r('cover_grid_show_title', gprefs) @@ -206,6 +212,12 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): for i in range(self.tabWidget.count()): if self.tabWidget.widget(i) is self.cover_grid_tab: self.tabWidget.removeTab(i) + self.size_calculated.connect(self.update_cg_cache_size, type=Qt.QueuedConnection) + self.tabWidget.currentChanged.connect(self.tab_changed) + self.cover_grid_empty_cache.clicked.connect(self.empty_cache) + self.cover_grid_open_cache.clicked.connect(self.open_cg_cache) + self.opt_cover_grid_disk_cache_size.setMinimum(self.gui.grid_view.thumbnail_cache.min_disk_cache) + self.opt_cover_grid_disk_cache_size.setMaximum(self.gui.grid_view.thumbnail_cache.min_disk_cache * 100) def initialize(self): ConfigWidgetBase.initialize(self) @@ -226,11 +238,34 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.icon_rules.initialize(db.field_metadata, db.prefs, mi, 'column_icon_rules') self.set_cg_color(gprefs['cover_grid_color']) + def open_cg_cache(self): + open_local_file(self.gui.grid_view.thumbnail_cache.location) + + def update_cg_cache_size(self, size): + self.cover_grid_current_disk_cache.setText( + _('Current space used: %s') % human_readable(size)) + + def tab_changed(self, index): + if self.tabWidget.currentWidget() is self.cover_grid_tab: + self.show_current_cache_usage() + + def show_current_cache_usage(self): + t = Thread(target=self.calc_cache_size) + t.daemon = True + t.start() + + def calc_cache_size(self): + self.size_calculated.emit(self.gui.grid_view.thumbnail_cache.current_size) + def set_cg_color(self, val): pal = QPalette() pal.setColor(QPalette.Window, QColor(*val)) self.cover_grid_color_label.setPalette(pal) + def empty_cache(self): + self.gui.grid_view.thumbnail_cache.empty() + self.calc_cache_size() + def restore_defaults(self): ConfigWidgetBase.restore_defaults(self) ofont = self.current_font diff --git a/src/calibre/gui2/preferences/look_feel.ui b/src/calibre/gui2/preferences/look_feel.ui index ca9c83d3d3..3669c2efea 100644 --- a/src/calibre/gui2/preferences/look_feel.ui +++ b/src/calibre/gui2/preferences/look_feel.ui @@ -231,147 +231,8 @@ Cover Grid - - - - - - - Cover &Width: - - - opt_cover_grid_width - - - - - - - The width of displayed covers - - - Automatic - - - cm - - - 1 - - - - - - - Cover &Height: - - - opt_cover_grid_height - - - - - - - The height of displayed covers - - - Automatic - - - cm - - - 1 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Background color for the cover grid: - - - cover_grid_color_button - - - - - - - - 50 - 50 - - - - true - - - - - - - - - - Change &color - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - Number of covers to cache in &memory: - - - opt_cover_grid_cache_size - - - - - - - The maximum number of covers to keep in memory. Increasing this will make rendering faster, at the cost of more memory usage. - - - 50000 - - - - + + &Spacing between covers: @@ -381,7 +242,172 @@ - + + + + Qt::Horizontal + + + + 417 + 20 + + + + + + + + + 50 + 50 + + + + true + + + + + + + + + + Caching of covers for improved performance + + + + + + There are two kinds of caches that calibre uses to improve performance when rendering covers in the grid view. A disk cache that is kept on your hard disk and stores the cover thumbnails and an in memory cache used to ensure flicker free rendering of covers. For best results, keep the memory cache small and the disk cache large, unless you have a lot of extra RAM in your computer and dont mind it being used by the memory cache. + + + true + + + + + + + Number of covers to cache in &memory (keep this small): + + + opt_cover_grid_cache_size + + + + + + + The maximum number of covers to keep in memory. Increasing this will make rendering faster, at the cost of more memory usage. + + + 50000 + + + + + + + Maximum amount of disk space to use for caching thumbnails: + + + + + + + Disable + + + MB + + + 100 + + + 100 + + + + + + + + + + + + + + Empty disk cache + + + + + + + Qt::Horizontal + + + + 310 + 20 + + + + + + + + Open cache directory + + + + + + + + + + Cover &Height: + + + opt_cover_grid_height + + + + + + + Qt::Horizontal + + + + 365 + 20 + + + + + + + + Change &color + + + + + + + Cover &Width: + + + opt_cover_grid_width + + + + The spacing between covers. A value of zero means calculate automatically based on cover size. @@ -397,13 +423,78 @@ - + + + + Background color for the cover grid: + + + cover_grid_color_button + + + + + + + The width of displayed covers + + + Automatic + + + cm + + + 1 + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + The height of displayed covers + + + Automatic + + + cm + + + 1 + + + + Show the book &title below the cover + + + + Control the cover grid view. You can enble this view by clicking the grid button in the bottom right corner of the main calibre window. + + + true + + +