Cover grid: Add a disk cache for rendered thumbnails

This commit is contained in:
Kovid Goyal 2013-08-12 13:40:36 +05:30
parent 437746f139
commit 9859812ec9
9 changed files with 768 additions and 171 deletions

View File

@ -1225,6 +1225,22 @@ class DB(object):
return True return True
return False 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): def set_cover(self, book_id, path, data):
path = os.path.abspath(os.path.join(self.library_path, path)) path = os.path.abspath(os.path.join(self.library_path, path))
if not os.path.exists(path): if not os.path.exists(path):

View File

@ -587,6 +587,14 @@ class Cache(object):
ret = i ret = i
return ret 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 @read_api
def cover_last_modified(self, book_id): def cover_last_modified(self, book_id):
try: try:
@ -1032,8 +1040,8 @@ class Cache(object):
path = self._field_for('path', book_id).replace('/', os.sep) path = self._field_for('path', book_id).replace('/', os.sep)
self.backend.set_cover(book_id, path, data) self.backend.set_cover(book_id, path, data)
for cc in self.cover_caches: for cc in self.cover_caches:
cc.invalidate(book_id) cc.invalidate(book_id_data_map)
return self._set_field('cover', { return self._set_field('cover', {
book_id:(0 if data is None else 1) for book_id, data in book_id_data_map.iteritems()}) 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._search_api.discard_books(book_ids)
self._clear_caches(book_ids=book_ids, template_cache=False, search_cache=False) self._clear_caches(book_ids=book_ids, template_cache=False, search_cache=False)
for cc in self.cover_caches: for cc in self.cover_caches:
for book_id in book_ids: cc.invalidate(book_ids)
cc.invalidate(book_id)
@read_api @read_api
def author_sort_strings_for_books(self, book_ids): def author_sort_strings_for_books(self, book_ids):

View File

@ -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 <kovid at kovidgoyal.net>'
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)), ())
# }}}

297
src/calibre/db/utils.py Normal file
View File

@ -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 <kovid at kovidgoyal.net>'
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()

View File

@ -115,6 +115,7 @@ defs['cover_grid_width'] = 0
defs['cover_grid_height'] = 0 defs['cover_grid_height'] = 0
defs['cover_grid_color'] = (80, 80, 80) defs['cover_grid_color'] = (80, 80, 80)
defs['cover_grid_cache_size'] = 200 defs['cover_grid_cache_size'] = 200
defs['cover_grid_disk_cache_size'] = 2000
defs['cover_grid_spacing'] = 0 defs['cover_grid_spacing'] = 0
defs['cover_grid_show_title'] = False defs['cover_grid_show_title'] = False
del defs del defs

View File

@ -19,14 +19,29 @@ from PyQt4.Qt import (
QTimer, QPalette, QColor, QItemSelection, QPixmap, QMenu, QApplication, QTimer, QPalette, QColor, QItemSelection, QPixmap, QMenu, QApplication,
QMimeData, QUrl, QDrag, QPoint, QPainter, QRect, pyqtProperty, QEvent, QMimeData, QUrl, QDrag, QPoint, QPainter, QRect, pyqtProperty, QEvent,
QPropertyAnimation, QEasingCurve, pyqtSlot, QHelpEvent, QAbstractItemView, 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 import gprefs, config
from calibre.gui2.library.caches import CoverCache from calibre.gui2.library.caches import CoverCache, ThumbnailCache
from calibre.utils.config import prefs from calibre.utils.config import prefs
CM_TO_INCH = 0.393701 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 {{{ # Drag 'n Drop {{{
def dragMoveEvent(self, event): def dragMoveEvent(self, event):
@ -443,6 +458,8 @@ class GridView(QListView):
self.setSpacing(self.delegate.spacing) self.setSpacing(self.delegate.spacing)
self.set_color() self.set_color()
self.ignore_render_requests = Event() 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.render_thread = None
self.update_item.connect(self.re_render, type=Qt.QueuedConnection) self.update_item.connect(self.re_render, type=Qt.QueuedConnection)
self.doubleClicked.connect(self.double_clicked) self.doubleClicked.connect(self.double_clicked)
@ -485,12 +502,10 @@ class GridView(QListView):
def slider_pressed(self): def slider_pressed(self):
self.ignore_render_requests.set() self.ignore_render_requests.set()
self.verticalScrollBar().valueChanged.connect(self.value_changed_during_scroll) self.verticalScrollBar().valueChanged.connect(self.value_changed_during_scroll)
self.update_timer.setInterval(500)
def slider_released(self): def slider_released(self):
self.update_viewport() self.update_viewport()
self.verticalScrollBar().valueChanged.disconnect(self.value_changed_during_scroll) self.verticalScrollBar().valueChanged.disconnect(self.value_changed_during_scroll)
self.update_timer.setInterval(200)
def value_changed_during_scroll(self): def value_changed_during_scroll(self):
if self.ignore_render_requests.is_set(): if self.ignore_render_requests.is_set():
@ -545,9 +560,15 @@ class GridView(QListView):
self.setSpacing(self.delegate.spacing) self.setSpacing(self.delegate.spacing)
self.set_color() self.set_color()
self.delegate.cover_cache.set_limit(gprefs['cover_grid_cache_size']) 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): def shown(self):
if self.render_thread is None: 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 = Thread(target=self.render_covers)
self.render_thread.daemon = True self.render_thread.daemon = True
self.render_thread.start() self.render_thread.start()
@ -572,19 +593,51 @@ class GridView(QListView):
def render_cover(self, book_id): def render_cover(self, book_id):
if self.ignore_render_requests.is_set(): if self.ignore_render_requests.is_set():
return return
cdata = self.model().db.new_api.cover(book_id) tcdata, timestamp = self.thumbnail_cache[book_id]
if cdata is not None: 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 = QImage()
p.loadFromData(cdata) p.loadFromData(cdata, CACHE_FORMAT if cdata is tcdata else 'JPEG')
cdata = None if p.isNull() and cdata is tcdata:
if not p.isNull(): # Invalid image in cache
width, height = p.width(), p.height() self.thumbnail_cache.invalidate((book_id,))
scaled, nwidth, nheight = fit_image(width, height, self.delegate.cover_size.width(), self.delegate.cover_size.height()) self.update_item.emit(book_id)
if scaled: return
if self.ignore_render_requests.is_set(): cdata = None if p.isNull() else p
return if not use_cache: # cache is stale
p = p.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) if cdata is not None:
cdata = p 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.delegate.cover_cache.set(book_id, cdata)
self.update_item.emit(book_id) self.update_item.emit(book_id)
@ -600,6 +653,7 @@ class GridView(QListView):
def shutdown(self): def shutdown(self):
self.ignore_render_requests.set() self.ignore_render_requests.set()
self.delegate.render_queue.put(None) self.delegate.render_queue.put(None)
self.thumbnail_cache.shutdown()
def set_database(self, newdb, stage=0): def set_database(self, newdb, stage=0):
if not hasattr(newdb, 'new_api'): if not hasattr(newdb, 'new_api'):
@ -607,10 +661,12 @@ class GridView(QListView):
if stage == 0: if stage == 0:
self.ignore_render_requests.set() self.ignore_render_requests.set()
try: 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: except AttributeError:
pass # db is None 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: try:
# Use a timeout so that if, for some reason, the render thread # Use a timeout so that if, for some reason, the render thread
# gets stuck, we dont deadlock, future covers wont get # gets stuck, we dont deadlock, future covers wont get

View File

@ -11,6 +11,15 @@ from collections import OrderedDict
from PyQt4.Qt import QImage, QPixmap 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): class CoverCache(dict):
' This is a RAM cache to speed up rendering of covers by storing them as QPixmaps ' ' 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 ' ' Must be called in the GUI thread '
self.pixmap_staging = [] self.pixmap_staging = []
def invalidate(self, book_id): def invalidate(self, book_ids):
with self.lock: with self.lock:
self._pop(book_id) for book_id in book_ids:
self._pop(book_id)
def _pop(self, book_id): def _pop(self, book_id):
val = self.items.pop(book_id, None) val = self.items.pop(book_id, None)
@ -75,3 +85,4 @@ class CoverCache(dict):
for k in remove: for k in remove:
self._pop(k) self._pop(k)

View File

@ -5,12 +5,15 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
from PyQt4.Qt import (QApplication, QFont, QFontInfo, QFontDialog, QColorDialog, from threading import Thread
QAbstractListModel, Qt, QIcon, QKeySequence, QPalette, QColor)
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 import ConfigWidgetBase, test_widget, CommaSeparatedList
from calibre.gui2.preferences.look_feel_ui import Ui_Form 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, from calibre.utils.localization import (available_translations,
get_language, get_lang) get_language, get_lang)
from calibre.utils.config import prefs, tweaks from calibre.utils.config import prefs, tweaks
@ -95,6 +98,8 @@ class DisplayedFields(QAbstractListModel): # {{{
class ConfigWidget(ConfigWidgetBase, Ui_Form): class ConfigWidget(ConfigWidgetBase, Ui_Form):
size_calculated = pyqtSignal(object)
def genesis(self, gui): def genesis(self, gui):
self.gui = gui self.gui = gui
db = gui.library_view.model().db db = gui.library_view.model().db
@ -113,6 +118,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
r('cover_grid_width', gprefs) r('cover_grid_width', gprefs)
r('cover_grid_height', gprefs) r('cover_grid_height', gprefs)
r('cover_grid_cache_size', gprefs) r('cover_grid_cache_size', gprefs)
r('cover_grid_disk_cache_size', gprefs)
r('cover_grid_spacing', gprefs) r('cover_grid_spacing', gprefs)
r('cover_grid_show_title', gprefs) r('cover_grid_show_title', gprefs)
@ -206,6 +212,12 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
for i in range(self.tabWidget.count()): for i in range(self.tabWidget.count()):
if self.tabWidget.widget(i) is self.cover_grid_tab: if self.tabWidget.widget(i) is self.cover_grid_tab:
self.tabWidget.removeTab(i) 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): def initialize(self):
ConfigWidgetBase.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.icon_rules.initialize(db.field_metadata, db.prefs, mi, 'column_icon_rules')
self.set_cg_color(gprefs['cover_grid_color']) 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): def set_cg_color(self, val):
pal = QPalette() pal = QPalette()
pal.setColor(QPalette.Window, QColor(*val)) pal.setColor(QPalette.Window, QColor(*val))
self.cover_grid_color_label.setPalette(pal) 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): def restore_defaults(self):
ConfigWidgetBase.restore_defaults(self) ConfigWidgetBase.restore_defaults(self)
ofont = self.current_font ofont = self.current_font

View File

@ -231,147 +231,8 @@
<attribute name="title"> <attribute name="title">
<string>Cover Grid</string> <string>Cover Grid</string>
</attribute> </attribute>
<layout class="QFormLayout" name="formLayout"> <layout class="QGridLayout" name="gridLayout_4">
<item row="0" column="0" colspan="2"> <item row="4" column="1" colspan="3">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="label_11">
<property name="text">
<string>Cover &amp;Width: </string>
</property>
<property name="buddy">
<cstring>opt_cover_grid_width</cstring>
</property>
</widget>
</item>
<item>
<widget class="QDoubleSpinBox" name="opt_cover_grid_width">
<property name="toolTip">
<string>The width of displayed covers</string>
</property>
<property name="specialValueText">
<string>Automatic</string>
</property>
<property name="suffix">
<string> cm</string>
</property>
<property name="decimals">
<number>1</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_12">
<property name="text">
<string>Cover &amp;Height: </string>
</property>
<property name="buddy">
<cstring>opt_cover_grid_height</cstring>
</property>
</widget>
</item>
<item>
<widget class="QDoubleSpinBox" name="opt_cover_grid_height">
<property name="toolTip">
<string>The height of displayed covers</string>
</property>
<property name="specialValueText">
<string>Automatic</string>
</property>
<property name="suffix">
<string> cm</string>
</property>
<property name="decimals">
<number>1</number>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item row="1" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLabel" name="label_14">
<property name="text">
<string>Background color for the cover grid:</string>
</property>
<property name="buddy">
<cstring>cover_grid_color_button</cstring>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="cover_grid_color_label">
<property name="minimumSize">
<size>
<width>50</width>
<height>50</height>
</size>
</property>
<property name="autoFillBackground">
<bool>true</bool>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cover_grid_color_button">
<property name="text">
<string>Change &amp;color</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_15">
<property name="text">
<string>Number of covers to cache in &amp;memory:</string>
</property>
<property name="buddy">
<cstring>opt_cover_grid_cache_size</cstring>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QSpinBox" name="opt_cover_grid_cache_size">
<property name="toolTip">
<string>The maximum number of covers to keep in memory. Increasing this will make rendering faster, at the cost of more memory usage.</string>
</property>
<property name="maximum">
<number>50000</number>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_16"> <widget class="QLabel" name="label_16">
<property name="text"> <property name="text">
<string>&amp;Spacing between covers:</string> <string>&amp;Spacing between covers:</string>
@ -381,7 +242,172 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="4" column="1"> <item row="1" column="7" colspan="2">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>417</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="3" colspan="3">
<widget class="QLabel" name="cover_grid_color_label">
<property name="minimumSize">
<size>
<width>50</width>
<height>50</height>
</size>
</property>
<property name="autoFillBackground">
<bool>true</bool>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="5" column="0" colspan="9">
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Caching of covers for improved performance</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="5">
<widget class="QLabel" name="label_13">
<property name="text">
<string>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.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="label_15">
<property name="text">
<string>Number of covers to cache in &amp;memory (keep this small):</string>
</property>
<property name="buddy">
<cstring>opt_cover_grid_cache_size</cstring>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QSpinBox" name="opt_cover_grid_cache_size">
<property name="toolTip">
<string>The maximum number of covers to keep in memory. Increasing this will make rendering faster, at the cost of more memory usage.</string>
</property>
<property name="maximum">
<number>50000</number>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QLabel" name="label_18">
<property name="text">
<string>Maximum amount of disk space to use for caching thumbnails: </string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QSpinBox" name="opt_cover_grid_disk_cache_size">
<property name="specialValueText">
<string>Disable</string>
</property>
<property name="suffix">
<string> MB</string>
</property>
<property name="minimum">
<number>100</number>
</property>
<property name="singleStep">
<number>100</number>
</property>
</widget>
</item>
<item row="2" column="3" colspan="2">
<widget class="QLabel" name="cover_grid_current_disk_cache">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="cover_grid_empty_cache">
<property name="text">
<string>Empty disk cache</string>
</property>
</widget>
</item>
<item row="3" column="2" colspan="3">
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>310</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="cover_grid_open_cache">
<property name="text">
<string>Open cache directory</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="1" column="2" colspan="3">
<widget class="QLabel" name="label_12">
<property name="text">
<string>Cover &amp;Height: </string>
</property>
<property name="buddy">
<cstring>opt_cover_grid_height</cstring>
</property>
</widget>
</item>
<item row="2" column="8">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>365</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="6" colspan="2">
<widget class="QPushButton" name="cover_grid_color_button">
<property name="text">
<string>Change &amp;color</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Cover &amp;Width: </string>
</property>
<property name="buddy">
<cstring>opt_cover_grid_width</cstring>
</property>
</widget>
</item>
<item row="4" column="4" colspan="3">
<widget class="QDoubleSpinBox" name="opt_cover_grid_spacing"> <widget class="QDoubleSpinBox" name="opt_cover_grid_spacing">
<property name="toolTip"> <property name="toolTip">
<string>The spacing between covers. A value of zero means calculate automatically based on cover size.</string> <string>The spacing between covers. A value of zero means calculate automatically based on cover size.</string>
@ -397,13 +423,78 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="0" colspan="2"> <item row="2" column="0" colspan="3">
<widget class="QLabel" name="label_14">
<property name="text">
<string>Background color for the cover grid:</string>
</property>
<property name="buddy">
<cstring>cover_grid_color_button</cstring>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="opt_cover_grid_width">
<property name="toolTip">
<string>The width of displayed covers</string>
</property>
<property name="specialValueText">
<string>Automatic</string>
</property>
<property name="suffix">
<string> cm</string>
</property>
<property name="decimals">
<number>1</number>
</property>
</widget>
</item>
<item row="6" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="1" column="5" colspan="2">
<widget class="QDoubleSpinBox" name="opt_cover_grid_height">
<property name="toolTip">
<string>The height of displayed covers</string>
</property>
<property name="specialValueText">
<string>Automatic</string>
</property>
<property name="suffix">
<string> cm</string>
</property>
<property name="decimals">
<number>1</number>
</property>
</widget>
</item>
<item row="3" column="0" colspan="9">
<widget class="QCheckBox" name="opt_cover_grid_show_title"> <widget class="QCheckBox" name="opt_cover_grid_show_title">
<property name="text"> <property name="text">
<string>Show the book &amp;title below the cover</string> <string>Show the book &amp;title below the cover</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="0" column="0" colspan="9">
<widget class="QLabel" name="label_19">
<property name="text">
<string>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.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout> </layout>
</widget> </widget>
<widget class="QWidget" name="tab_4"> <widget class="QWidget" name="tab_4">