Added series cover thumbnail generation. Better cache file handling.

This commit is contained in:
mmonkey 2021-09-25 03:04:38 -05:00
parent be28a91315
commit 0bd544704d
21 changed files with 430 additions and 92 deletions

View File

@ -166,7 +166,7 @@ def clear_cache():
cache_type = request.args.get('cache_type'.strip()) cache_type = request.args.get('cache_type'.strip())
showtext = {} showtext = {}
if cache_type == fs.CACHE_TYPE_THUMBNAILS: if cache_type == constants.CACHE_TYPE_THUMBNAILS:
log.info('clearing cover thumbnail cache') log.info('clearing cover thumbnail cache')
showtext['text'] = _(u'Cleared cover thumbnail cache') showtext['text'] = _(u'Cleared cover thumbnail cache')
helper.clear_cover_thumbnail_cache() helper.clear_cover_thumbnail_cache()

View File

@ -169,6 +169,19 @@ NIGHTLY_VERSION[1] = '$Format:%cI$'
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57' # NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00' # NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
# CACHE
CACHE_TYPE_THUMBNAILS = 'thumbnails'
# Thumbnail Types
THUMBNAIL_TYPE_COVER = 1
THUMBNAIL_TYPE_SERIES = 2
THUMBNAIL_TYPE_AUTHOR = 3
# Thumbnails Sizes
COVER_THUMBNAIL_ORIGINAL = 0
COVER_THUMBNAIL_SMALL = 1
COVER_THUMBNAIL_MEDIUM = 2
COVER_THUMBNAIL_LARGE = 3
# clean-up the module namespace # clean-up the module namespace
del sys, os, namedtuple del sys, os, namedtuple

View File

@ -19,12 +19,10 @@
from __future__ import division, print_function, unicode_literals from __future__ import division, print_function, unicode_literals
from . import logger from . import logger
from .constants import CACHE_DIR from .constants import CACHE_DIR
from os import listdir, makedirs, remove from os import makedirs, remove
from os.path import isdir, isfile, join from os.path import isdir, isfile, join
from shutil import rmtree from shutil import rmtree
CACHE_TYPE_THUMBNAILS = 'thumbnails'
class FileSystem: class FileSystem:
_instance = None _instance = None
@ -54,8 +52,19 @@ class FileSystem:
return path if cache_type else self._cache_dir return path if cache_type else self._cache_dir
def get_cache_file_dir(self, filename, cache_type=None):
path = join(self.get_cache_dir(cache_type), filename[:2])
if not isdir(path):
try:
makedirs(path)
except OSError:
self.log.info(f'Failed to create path {path} (Permission denied).')
return False
return path
def get_cache_file_path(self, filename, cache_type=None): def get_cache_file_path(self, filename, cache_type=None):
return join(self.get_cache_dir(cache_type), filename) if filename else None return join(self.get_cache_file_dir(filename, cache_type), filename) if filename else None
def get_cache_file_exists(self, filename, cache_type=None): def get_cache_file_exists(self, filename, cache_type=None):
path = self.get_cache_file_path(filename, cache_type) path = self.get_cache_file_path(filename, cache_type)
@ -78,7 +87,7 @@ class FileSystem:
return False return False
def delete_cache_file(self, filename, cache_type=None): def delete_cache_file(self, filename, cache_type=None):
path = join(self.get_cache_dir(cache_type), filename) path = self.get_cache_file_path(filename, cache_type)
if isfile(path): if isfile(path):
try: try:
remove(path) remove(path)

View File

@ -55,7 +55,7 @@ from . import calibre_db
from .tasks.convert import TaskConvert from .tasks.convert import TaskConvert
from . import logger, config, get_locale, db, fs, ub from . import logger, config, get_locale, db, fs, ub
from . import gdriveutils as gd from . import gdriveutils as gd
from .constants import STATIC_DIR as _STATIC_DIR from .constants import STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES
from .subproc_wrapper import process_wait from .subproc_wrapper import process_wait
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS
from .tasks.mail import TaskEmail from .tasks.mail import TaskEmail
@ -575,8 +575,9 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None)
thumbnail = get_book_cover_thumbnail(book, resolution) thumbnail = get_book_cover_thumbnail(book, resolution)
if thumbnail: if thumbnail:
cache = fs.FileSystem() cache = fs.FileSystem()
if cache.get_cache_file_exists(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): if cache.get_cache_file_exists(thumbnail.filename, CACHE_TYPE_THUMBNAILS):
return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) return send_from_directory(cache.get_cache_file_dir(thumbnail.filename, CACHE_TYPE_THUMBNAILS),
thumbnail.filename)
# Send the book cover from Google Drive if configured # Send the book cover from Google Drive if configured
if config.config_use_google_drive: if config.config_use_google_drive:
@ -606,14 +607,54 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None)
def get_book_cover_thumbnail(book, resolution): def get_book_cover_thumbnail(book, resolution):
if book and book.has_cover: if book and book.has_cover:
return ub.session\ return ub.session \
.query(ub.Thumbnail)\ .query(ub.Thumbnail) \
.filter(ub.Thumbnail.book_id == book.id)\ .filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \
.filter(ub.Thumbnail.resolution == resolution)\ .filter(ub.Thumbnail.entity_id == book.id) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\ .filter(ub.Thumbnail.resolution == resolution) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.first() .first()
def get_series_thumbnail_on_failure(series_id, resolution):
book = calibre_db.session \
.query(db.Books) \
.join(db.books_series_link) \
.join(db.Series) \
.filter(db.Series.id == series_id) \
.filter(db.Books.has_cover == 1) \
.first()
return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution)
def get_series_cover_thumbnail(series_id, resolution=None):
return get_series_cover_internal(series_id, resolution)
def get_series_cover_internal(series_id, resolution=None):
# Send the series thumbnail if it exists in cache
if resolution:
thumbnail = get_series_thumbnail(series_id, resolution)
if thumbnail:
cache = fs.FileSystem()
if cache.get_cache_file_exists(thumbnail.filename, CACHE_TYPE_THUMBNAILS):
return send_from_directory(cache.get_cache_file_dir(thumbnail.filename, CACHE_TYPE_THUMBNAILS),
thumbnail.filename)
return get_series_thumbnail_on_failure(series_id, resolution)
def get_series_thumbnail(series_id, resolution):
return ub.session \
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) \
.filter(ub.Thumbnail.entity_id == series_id) \
.filter(ub.Thumbnail.resolution == resolution) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.first()
# saves book cover from url # saves book cover from url
def save_cover_from_url(url, book_path): def save_cover_from_url(url, book_path):
try: try:

View File

@ -32,9 +32,7 @@ from flask import Blueprint, request, url_for
from flask_babel import get_locale from flask_babel import get_locale
from flask_login import current_user from flask_login import current_user
from markupsafe import escape from markupsafe import escape
from . import logger from . import constants, logger
from .tasks.thumbnail import THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X, THUMBNAIL_RESOLUTION_3X
jinjia = Blueprint('jinjia', __name__) jinjia = Blueprint('jinjia', __name__)
log = logger.create() log = logger.create()
@ -141,17 +139,44 @@ def uuidfilter(var):
return uuid4() return uuid4()
@jinjia.app_template_filter('cache_timestamp')
def cache_timestamp(rolling_period='month'):
if rolling_period == 'day':
return str(int(datetime.datetime.today().replace(hour=1, minute=1).timestamp()))
elif rolling_period == 'year':
return str(int(datetime.datetime.today().replace(day=1).timestamp()))
else:
return str(int(datetime.datetime.today().replace(month=1, day=1).timestamp()))
@jinjia.app_template_filter('last_modified') @jinjia.app_template_filter('last_modified')
def book_cover_cache_id(book): def book_last_modified(book):
timestamp = int(book.last_modified.timestamp() * 1000) return str(int(book.last_modified.timestamp()))
return str(timestamp)
@jinjia.app_template_filter('get_cover_srcset') @jinjia.app_template_filter('get_cover_srcset')
def get_cover_srcset(book): def get_cover_srcset(book):
srcset = list() srcset = list()
for resolution in [THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X, THUMBNAIL_RESOLUTION_3X]: resolutions = {
timestamp = int(book.last_modified.timestamp() * 1000) constants.COVER_THUMBNAIL_SMALL: 'sm',
url = url_for('web.get_cover', book_id=book.id, resolution=resolution, cache_bust=str(timestamp)) constants.COVER_THUMBNAIL_MEDIUM: 'md',
constants.COVER_THUMBNAIL_LARGE: 'lg'
}
for resolution, shortname in resolutions.items():
url = url_for('web.get_cover', book_id=book.id, resolution=shortname, c=book_last_modified(book))
srcset.append(f'{url} {resolution}x')
return ', '.join(srcset)
@jinjia.app_template_filter('get_series_srcset')
def get_cover_srcset(series):
srcset = list()
resolutions = {
constants.COVER_THUMBNAIL_SMALL: 'sm',
constants.COVER_THUMBNAIL_MEDIUM: 'md',
constants.COVER_THUMBNAIL_LARGE: 'lg'
}
for resolution, shortname in resolutions.items():
url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
srcset.append(f'{url} {resolution}x') srcset.append(f'{url} {resolution}x')
return ', '.join(srcset) return ', '.join(srcset)

View File

@ -21,7 +21,7 @@ from __future__ import division, print_function, unicode_literals
from .services.background_scheduler import BackgroundScheduler from .services.background_scheduler import BackgroundScheduler
from .services.worker import WorkerThread from .services.worker import WorkerThread
from .tasks.database import TaskReconnectDatabase from .tasks.database import TaskReconnectDatabase
from .tasks.thumbnail import TaskGenerateCoverThumbnails from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails
def register_jobs(): def register_jobs():
@ -37,3 +37,4 @@ def register_jobs():
def register_startup_jobs(): def register_startup_jobs():
WorkerThread.add(None, TaskGenerateCoverThumbnails()) WorkerThread.add(None, TaskGenerateCoverThumbnails())
# WorkerThread.add(None, TaskGenerateSeriesThumbnails())

View File

@ -19,10 +19,11 @@
from __future__ import division, print_function, unicode_literals from __future__ import division, print_function, unicode_literals
import os import os
from .. import constants
from cps import config, db, fs, gdriveutils, logger, ub from cps import config, db, fs, gdriveutils, logger, ub
from cps.services.worker import CalibreTask from cps.services.worker import CalibreTask
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy import or_ from sqlalchemy import func, text, or_
try: try:
from urllib.request import urlopen from urllib.request import urlopen
@ -35,9 +36,34 @@ try:
except (ImportError, RuntimeError) as e: except (ImportError, RuntimeError) as e:
use_IM = False use_IM = False
THUMBNAIL_RESOLUTION_1X = 1
THUMBNAIL_RESOLUTION_2X = 2 def get_resize_height(resolution):
THUMBNAIL_RESOLUTION_3X = 3 return int(225 * resolution)
def get_resize_width(resolution, original_width, original_height):
height = get_resize_height(resolution)
percent = (height / float(original_height))
width = int((float(original_width) * float(percent)))
return width if width % 2 == 0 else width + 1
def get_best_fit(width, height, image_width, image_height):
resize_width = int(width / 2.0)
resize_height = int(height / 2.0)
aspect_ratio = image_width / image_height
# If this image's aspect ratio is different than the first image, then resize this image
# to fill the width and height of the first image
if aspect_ratio < width / height:
resize_width = int(width / 2.0)
resize_height = image_height * int(width / 2.0) / image_width
elif aspect_ratio > width / height:
resize_width = image_width * int(height / 2.0) / image_height
resize_height = int(height / 2.0)
return {'width': resize_width, 'height': resize_height}
class TaskGenerateCoverThumbnails(CalibreTask): class TaskGenerateCoverThumbnails(CalibreTask):
@ -48,8 +74,8 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self.calibre_db = db.CalibreDB(expire_on_commit=False) self.calibre_db = db.CalibreDB(expire_on_commit=False)
self.cache = fs.FileSystem() self.cache = fs.FileSystem()
self.resolutions = [ self.resolutions = [
THUMBNAIL_RESOLUTION_1X, constants.COVER_THUMBNAIL_SMALL,
THUMBNAIL_RESOLUTION_2X constants.COVER_THUMBNAIL_MEDIUM
] ]
def run(self, worker_thread): def run(self, worker_thread):
@ -75,7 +101,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
updated += 1 updated += 1
self.update_book_cover_thumbnail(book, thumbnail) self.update_book_cover_thumbnail(book, thumbnail)
elif not self.cache.get_cache_file_exists(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
updated += 1 updated += 1
self.update_book_cover_thumbnail(book, thumbnail) self.update_book_cover_thumbnail(book, thumbnail)
@ -86,21 +112,23 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self.app_db_session.remove() self.app_db_session.remove()
def get_books_with_covers(self): def get_books_with_covers(self):
return self.calibre_db.session\ return self.calibre_db.session \
.query(db.Books)\ .query(db.Books) \
.filter(db.Books.has_cover == 1)\ .filter(db.Books.has_cover == 1) \
.all() .all()
def get_book_cover_thumbnails(self, book_id): def get_book_cover_thumbnails(self, book_id):
return self.app_db_session\ return self.app_db_session \
.query(ub.Thumbnail)\ .query(ub.Thumbnail) \
.filter(ub.Thumbnail.book_id == book_id)\ .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\ .filter(ub.Thumbnail.entity_id == book_id) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.all() .all()
def create_book_cover_thumbnail(self, book, resolution): def create_book_cover_thumbnail(self, book, resolution):
thumbnail = ub.Thumbnail() thumbnail = ub.Thumbnail()
thumbnail.book_id = book.id thumbnail.type = constants.THUMBNAIL_TYPE_COVER
thumbnail.entity_id = book.id
thumbnail.format = 'jpeg' thumbnail.format = 'jpeg'
thumbnail.resolution = resolution thumbnail.resolution = resolution
@ -118,7 +146,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
try: try:
self.app_db_session.commit() self.app_db_session.commit()
self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
self.generate_book_thumbnail(book, thumbnail) self.generate_book_thumbnail(book, thumbnail)
except Exception as ex: except Exception as ex:
self.log.info(u'Error updating book thumbnail: ' + str(ex)) self.log.info(u'Error updating book thumbnail: ' + str(ex))
@ -144,7 +172,8 @@ class TaskGenerateCoverThumbnails(CalibreTask):
width = self.get_thumbnail_width(height, img) width = self.get_thumbnail_width(height, img)
img.resize(width=width, height=height, filter='lanczos') img.resize(width=width, height=height, filter='lanczos')
img.format = thumbnail.format img.format = thumbnail.format
filename = self.cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) filename = self.cache.get_cache_file_path(thumbnail.filename,
constants.CACHE_TYPE_THUMBNAILS)
img.save(filename=filename) img.save(filename=filename)
except Exception as ex: except Exception as ex:
# Bubble exception to calling function # Bubble exception to calling function
@ -158,26 +187,212 @@ class TaskGenerateCoverThumbnails(CalibreTask):
raise Exception('Book cover file not found') raise Exception('Book cover file not found')
with Image(filename=book_cover_filepath) as img: with Image(filename=book_cover_filepath) as img:
height = self.get_thumbnail_height(thumbnail) height = get_resize_height(thumbnail.resolution)
if img.height > height: if img.height > height:
width = self.get_thumbnail_width(height, img) width = get_resize_width(thumbnail.resolution, img.width, img.height)
img.resize(width=width, height=height, filter='lanczos') img.resize(width=width, height=height, filter='lanczos')
img.format = thumbnail.format img.format = thumbnail.format
filename = self.cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
img.save(filename=filename) img.save(filename=filename)
def get_thumbnail_height(self, thumbnail):
return int(225 * thumbnail.resolution)
def get_thumbnail_width(self, height, img):
percent = (height / float(img.height))
return int((float(img.width) * float(percent)))
@property @property
def name(self): def name(self):
return "ThumbnailsGenerate" return "ThumbnailsGenerate"
class TaskGenerateSeriesThumbnails(CalibreTask):
def __init__(self, task_message=u'Generating series thumbnails'):
super(TaskGenerateSeriesThumbnails, self).__init__(task_message)
self.log = logger.create()
self.app_db_session = ub.get_new_session_instance()
self.calibre_db = db.CalibreDB(expire_on_commit=False)
self.cache = fs.FileSystem()
self.resolutions = [
constants.COVER_THUMBNAIL_SMALL,
constants.COVER_THUMBNAIL_MEDIUM
]
# get all series
# get all books in series with covers and count >= 4 books
# get the dimensions from the first book in the series & pop the first book from the series list of books
# randomly select three other books in the series
# resize the covers in the sequence?
# create an image sequence from the 4 selected books of the series
# join pairs of books in the series with wand's concat
# join the two sets of pairs with wand's
def run(self, worker_thread):
if self.calibre_db.session and use_IM:
all_series = self.get_series_with_four_plus_books()
count = len(all_series)
updated = 0
generated = 0
for i, series in enumerate(all_series):
series_thumbnails = self.get_series_thumbnails(series.id)
series_books = self.get_series_books(series.id)
# Generate new thumbnails for missing covers
resolutions = list(map(lambda t: t.resolution, series_thumbnails))
missing_resolutions = list(set(self.resolutions).difference(resolutions))
for resolution in missing_resolutions:
generated += 1
self.create_series_thumbnail(series, series_books, resolution)
# Replace outdated or missing thumbnails
for thumbnail in series_thumbnails:
if any(book.last_modified > thumbnail.generated_at for book in series_books):
updated += 1
self.update_series_thumbnail(series_books, thumbnail)
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
updated += 1
self.update_series_thumbnail(series_books, thumbnail)
self.message = u'Processing series {0} of {1}'.format(i + 1, count)
self.progress = (1.0 / count) * i
self._handleSuccess()
self.app_db_session.remove()
def get_series_with_four_plus_books(self):
return self.calibre_db.session \
.query(db.Series) \
.join(db.books_series_link) \
.join(db.Books) \
.filter(db.Books.has_cover == 1) \
.group_by(text('books_series_link.series')) \
.having(func.count('book_series_link') > 3) \
.all()
def get_series_books(self, series_id):
return self.calibre_db.session \
.query(db.Books) \
.join(db.books_series_link) \
.join(db.Series) \
.filter(db.Books.has_cover == 1) \
.filter(db.Series.id == series_id) \
.all()
def get_series_thumbnails(self, series_id):
return self.app_db_session \
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) \
.filter(ub.Thumbnail.entity_id == series_id) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.all()
def create_series_thumbnail(self, series, series_books, resolution):
thumbnail = ub.Thumbnail()
thumbnail.type = constants.THUMBNAIL_TYPE_SERIES
thumbnail.entity_id = series.id
thumbnail.format = 'jpeg'
thumbnail.resolution = resolution
self.app_db_session.add(thumbnail)
try:
self.app_db_session.commit()
self.generate_series_thumbnail(series_books, thumbnail)
except Exception as ex:
self.log.info(u'Error creating book thumbnail: ' + str(ex))
self._handleError(u'Error creating book thumbnail: ' + str(ex))
self.app_db_session.rollback()
def update_series_thumbnail(self, series_books, thumbnail):
thumbnail.generated_at = datetime.utcnow()
try:
self.app_db_session.commit()
self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
self.generate_series_thumbnail(series_books, thumbnail)
except Exception as ex:
self.log.info(u'Error updating book thumbnail: ' + str(ex))
self._handleError(u'Error updating book thumbnail: ' + str(ex))
self.app_db_session.rollback()
def generate_series_thumbnail(self, series_books, thumbnail):
books = series_books[:4]
top = 0
left = 0
width = 0
height = 0
with Image() as canvas:
for book in books:
if config.config_use_google_drive:
if not gdriveutils.is_gdrive_ready():
raise Exception('Google Drive is configured but not ready')
web_content_link = gdriveutils.get_cover_via_gdrive(book.path)
if not web_content_link:
raise Exception('Google Drive cover url not found')
stream = None
try:
stream = urlopen(web_content_link)
with Image(file=stream) as img:
# Use the first image in this set to determine the width and height to scale the
# other images in this set
if width == 0 or height == 0:
width = get_resize_width(thumbnail.resolution, img.width, img.height)
height = get_resize_height(thumbnail.resolution)
canvas.blank(width, height)
dimensions = get_best_fit(width, height, img.width, img.height)
# resize and crop the image
img.resize(width=int(dimensions['width']), height=int(dimensions['height']), filter='lanczos')
img.crop(width=int(width / 2.0), height=int(height / 2.0), gravity='center')
# add the image to the canvas
canvas.composite(img, left, top)
except Exception as ex:
self.log.info(u'Error generating thumbnail file: ' + str(ex))
raise ex
finally:
stream.close()
book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg')
if not os.path.isfile(book_cover_filepath):
raise Exception('Book cover file not found')
with Image(filename=book_cover_filepath) as img:
# Use the first image in this set to determine the width and height to scale the
# other images in this set
if width == 0 or height == 0:
width = get_resize_width(thumbnail.resolution, img.width, img.height)
height = get_resize_height(thumbnail.resolution)
canvas.blank(width, height)
dimensions = get_best_fit(width, height, img.width, img.height)
# resize and crop the image
img.resize(width=int(dimensions['width']), height=int(dimensions['height']), filter='lanczos')
img.crop(width=int(width / 2.0), height=int(height / 2.0), gravity='center')
# add the image to the canvas
canvas.composite(img, left, top)
# set the coordinates for the next iteration
if left == 0 and top == 0:
left = int(width / 2.0)
elif left == int(width / 2.0) and top == 0:
left = 0
top = int(height / 2.0)
else:
left = int(width / 2.0)
canvas.format = thumbnail.format
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
canvas.save(filename=filename)
@property
def name(self):
return "SeriesThumbnailGenerate"
class TaskClearCoverThumbnailCache(CalibreTask): class TaskClearCoverThumbnailCache(CalibreTask):
def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'): def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'):
super(TaskClearCoverThumbnailCache, self).__init__(task_message) super(TaskClearCoverThumbnailCache, self).__init__(task_message)
@ -199,21 +414,22 @@ class TaskClearCoverThumbnailCache(CalibreTask):
self.app_db_session.remove() self.app_db_session.remove()
def get_thumbnails_for_book(self, book_id): def get_thumbnails_for_book(self, book_id):
return self.app_db_session\ return self.app_db_session \
.query(ub.Thumbnail)\ .query(ub.Thumbnail) \
.filter(ub.Thumbnail.book_id == book_id)\ .filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
.filter(ub.Thumbnail.entity_id == book_id) \
.all() .all()
def delete_thumbnail(self, thumbnail): def delete_thumbnail(self, thumbnail):
try: try:
self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
except Exception as ex: except Exception as ex:
self.log.info(u'Error deleting book thumbnail: ' + str(ex)) self.log.info(u'Error deleting book thumbnail: ' + str(ex))
self._handleError(u'Error deleting book thumbnail: ' + str(ex)) self._handleError(u'Error deleting book thumbnail: ' + str(ex))
def delete_all_thumbnails(self): def delete_all_thumbnails(self):
try: try:
self.cache.delete_cache_dir(fs.CACHE_TYPE_THUMBNAILS) self.cache.delete_cache_dir(constants.CACHE_TYPE_THUMBNAILS)
except Exception as ex: except Exception as ex:
self.log.info(u'Error deleting book thumbnails: ' + str(ex)) self.log.info(u'Error deleting book thumbnails: ' + str(ex))
self._handleError(u'Error deleting book thumbnails: ' + str(ex)) self._handleError(u'Error deleting book thumbnails: ' + str(ex))

View File

@ -37,7 +37,7 @@
<div class="cover"> <div class="cover">
<a href="{{ url_for('web.show_book', book_id=entry.id) }}"> <a href="{{ url_for('web.show_book', book_id=entry.id) }}">
<span class="img"> <span class="img">
{{ book_cover_image(entry, title=author.name|safe) }} {{ image.book_cover(entry, title=author.name|safe) }}
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %} {% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
</span> </span>
</a> </a>

View File

@ -1,11 +0,0 @@
{% macro book_cover_image(book, title=None) -%}
{%- set book_title = book.title if book.title else book.name -%}
{%- set book_title = title if title else book_title -%}
{% set srcset = book|get_cover_srcset %}
<img
srcset="{{ srcset }}"
src="{{ url_for('web.get_cover', book_id=book.id, resolution=0, cache_bust=book|last_modified) }}"
title="{{ book_title }}"
alt="{{ book_title }}"
/>
{%- endmacro %}

View File

@ -1,10 +1,10 @@
{% from 'book_cover.html' import book_cover_image %} {% import 'image.html' as image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
{% if book %} {% if book %}
<div class="col-sm-3 col-lg-3 col-xs-12"> <div class="col-sm-3 col-lg-3 col-xs-12">
<div class="cover"> <div class="cover">
{{ book_cover_image(book) }} {{ image.book_cover(book) }}
</div> </div>
{% if g.user.role_delete_books() %} {% if g.user.role_delete_books() %}
<div class="text-center"> <div class="text-center">

View File

@ -4,7 +4,7 @@
<div class="row"> <div class="row">
<div class="col-sm-3 col-lg-3 col-xs-5"> <div class="col-sm-3 col-lg-3 col-xs-5">
<div class="cover"> <div class="cover">
{{ book_cover_image(entry) }} {{ image.book_cover(entry) }}
</div> </div>
</div> </div>
<div class="col-sm-9 col-lg-9 book-meta"> <div class="col-sm-9 col-lg-9 book-meta">

View File

@ -1,4 +1,4 @@
{% from 'book_cover.html' import book_cover_image %} {% import 'image.html' as image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="discover load-more"> <div class="discover load-more">
@ -10,7 +10,7 @@
{% if entry.has_cover is defined %} {% if entry.has_cover is defined %}
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"> <a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
<span class="img"> <span class="img">
{{ book_cover_image(entry) }} {{ image.book_cover(entry) }}
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %} {% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
</span> </span>
</a> </a>

View File

@ -1,4 +1,4 @@
{% from 'book_cover.html' import book_cover_image %} {% import 'image.html' as image %}
<div class="container-fluid"> <div class="container-fluid">
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>

View File

@ -1,4 +1,4 @@
{% from 'book_cover.html' import book_cover_image %} {% import 'image.html' as image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<h1 class="{{page}}">{{_(title)}}</h1> <h1 class="{{page}}">{{_(title)}}</h1>
@ -29,7 +29,7 @@
<div class="cover"> <div class="cover">
<a href="{{url_for('web.books_list', data=data, sort_param='stored', book_id=entry[0].series[0].id )}}"> <a href="{{url_for('web.books_list', data=data, sort_param='stored', book_id=entry[0].series[0].id )}}">
<span class="img"> <span class="img">
{{ book_cover_image(entry[0], title=entry[0].series[0].name|shortentitle) }} {{ image.series(entry[0].series[0], title=entry[0].series[0].name|shortentitle) }}
<span class="badge">{{entry.count}}</span> <span class="badge">{{entry.count}}</span>
</span> </span>
</a> </a>

23
cps/templates/image.html Normal file
View File

@ -0,0 +1,23 @@
{% macro book_cover(book, title=None, alt=None) -%}
{%- set image_title = book.title if book.title else book.name -%}
{%- set image_title = title if title else image_title -%}
{%- set image_alt = alt if alt else image_title -%}
{% set srcset = book|get_cover_srcset %}
<img
srcset="{{ srcset }}"
src="{{ url_for('web.get_cover', book_id=book.id, resolution='og', c=book|last_modified) }}"
title="{{ image_title }}"
alt="{{ image_alt }}"
/>
{%- endmacro %}
{% macro series(series, title=None, alt=None) -%}
{%- set image_alt = alt if alt else image_title -%}
{% set srcset = series|get_series_srcset %}
<img
srcset="{{ srcset }}"
src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='month'|cache_timestamp) }}"
title="{{ title }}"
alt="{{ book_title }}"
/>
{%- endmacro %}

View File

@ -1,4 +1,4 @@
{% from 'book_cover.html' import book_cover_image %} {% import 'image.html' as image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
{% if g.user.show_detail_random() %} {% if g.user.show_detail_random() %}
@ -10,7 +10,7 @@
<div class="cover"> <div class="cover">
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"> <a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
<span class="img"> <span class="img">
{{ book_cover_image(entry) }} {{ image.book_cover(entry) }}
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %} {% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
</span> </span>
</a> </a>
@ -87,7 +87,7 @@
<div class="cover"> <div class="cover">
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"> <a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
<span class="img"> <span class="img">
{{ book_cover_image(entry) }} {{ image.book_cover(entry) }}
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %} {% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
</span> </span>
</a> </a>

View File

@ -1,5 +1,5 @@
{% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %} {% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %}
{% from 'book_cover.html' import book_cover_image %} {% import 'image.html' as image %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ g.user.locale }}"> <html lang="{{ g.user.locale }}">
<head> <head>

View File

@ -1,4 +1,4 @@
{% from 'book_cover.html' import book_cover_image %} {% import 'image.html' as image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="discover"> <div class="discover">
@ -45,7 +45,7 @@
{% if entry.has_cover is defined %} {% if entry.has_cover is defined %}
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"> <a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
<span class="img"> <span class="img">
{{ book_cover_image(entry) }} {{ image.book_cover(entry) }}
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %} {% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
</span> </span>
</a> </a>

View File

@ -1,4 +1,4 @@
{% from 'book_cover.html' import book_cover_image %} {% import 'image.html' as image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="discover"> <div class="discover">
@ -32,7 +32,7 @@
<div class="cover"> <div class="cover">
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"> <a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
<span class="img"> <span class="img">
{{ book_cover_image(entry) }} {{ image.book_cover(entry) }}
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %} {% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
</span> </span>
</a> </a>

View File

@ -526,10 +526,11 @@ class Thumbnail(Base):
__tablename__ = 'thumbnail' __tablename__ = 'thumbnail'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
book_id = Column(Integer) entity_id = Column(Integer)
uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True) uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True)
format = Column(String, default='jpeg') format = Column(String, default='jpeg')
resolution = Column(SmallInteger, default=1) type = Column(SmallInteger, default=constants.THUMBNAIL_TYPE_COVER)
resolution = Column(SmallInteger, default=constants.COVER_THUMBNAIL_SMALL)
filename = Column(String, default=filename) filename = Column(String, default=filename)
generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow()) generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow())
expiration = Column(DateTime, nullable=True) expiration = Column(DateTime, nullable=True)

View File

@ -50,8 +50,8 @@ from . import constants, logger, isoLanguages, services
from . import babel, db, ub, config, get_locale, app from . import babel, db, ub, config, get_locale, app
from . import calibre_db from . import calibre_db
from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download
from .helper import check_valid_domain, render_task_status, check_email, check_username, \ from .helper import check_valid_domain, render_task_status, check_email, check_username, get_cc_columns, \
get_cc_columns, get_book_cover, get_download_link, send_mail, generate_random_password, \ get_book_cover, get_series_cover_thumbnail, get_download_link, send_mail, generate_random_password, \
send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password, valid_email send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password, valid_email
from .pagination import Pagination from .pagination import Pagination
from .redirect import redirect_back from .redirect import redirect_back
@ -1388,11 +1388,31 @@ def advanced_search_form():
@web.route("/cover/<int:book_id>") @web.route("/cover/<int:book_id>")
@web.route("/cover/<int:book_id>/<int:resolution>") @web.route("/cover/<int:book_id>/<string:resolution>")
@web.route("/cover/<int:book_id>/<int:resolution>/<string:cache_bust>")
@login_required_if_no_ano @login_required_if_no_ano
def get_cover(book_id, resolution=None, cache_bust=None): def get_cover(book_id, resolution=None):
return get_book_cover(book_id, resolution) resolutions = {
'og': constants.COVER_THUMBNAIL_ORIGINAL,
'sm': constants.COVER_THUMBNAIL_SMALL,
'md': constants.COVER_THUMBNAIL_MEDIUM,
'lg': constants.COVER_THUMBNAIL_LARGE,
}
cover_resolution = resolutions.get(resolution, None)
return get_book_cover(book_id, cover_resolution)
@web.route("/series_cover/<int:series_id>")
@web.route("/series_cover/<int:series_id>/<string:resolution>")
@login_required_if_no_ano
def get_series_cover(series_id, resolution=None):
resolutions = {
'og': constants.COVER_THUMBNAIL_ORIGINAL,
'sm': constants.COVER_THUMBNAIL_SMALL,
'md': constants.COVER_THUMBNAIL_MEDIUM,
'lg': constants.COVER_THUMBNAIL_LARGE,
}
cover_resolution = resolutions.get(resolution, None)
return get_series_cover_thumbnail(series_id, cover_resolution)
@web.route("/robots.txt") @web.route("/robots.txt")