1
0
mirror of https://github.com/janeczku/calibre-web synced 2025-11-09 19:53:02 +00:00

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

@@ -19,10 +19,11 @@
from __future__ import division, print_function, unicode_literals
import os
from .. import constants
from cps import config, db, fs, gdriveutils, logger, ub
from cps.services.worker import CalibreTask
from datetime import datetime, timedelta
from sqlalchemy import or_
from sqlalchemy import func, text, or_
try:
from urllib.request import urlopen
@@ -35,9 +36,34 @@ try:
except (ImportError, RuntimeError) as e:
use_IM = False
THUMBNAIL_RESOLUTION_1X = 1
THUMBNAIL_RESOLUTION_2X = 2
THUMBNAIL_RESOLUTION_3X = 3
def get_resize_height(resolution):
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):
@@ -48,8 +74,8 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self.calibre_db = db.CalibreDB(expire_on_commit=False)
self.cache = fs.FileSystem()
self.resolutions = [
THUMBNAIL_RESOLUTION_1X,
THUMBNAIL_RESOLUTION_2X
constants.COVER_THUMBNAIL_SMALL,
constants.COVER_THUMBNAIL_MEDIUM
]
def run(self, worker_thread):
@@ -75,7 +101,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
updated += 1
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
self.update_book_cover_thumbnail(book, thumbnail)
@@ -86,21 +112,23 @@ class TaskGenerateCoverThumbnails(CalibreTask):
self.app_db_session.remove()
def get_books_with_covers(self):
return self.calibre_db.session\
.query(db.Books)\
.filter(db.Books.has_cover == 1)\
return self.calibre_db.session \
.query(db.Books) \
.filter(db.Books.has_cover == 1) \
.all()
def get_book_cover_thumbnails(self, book_id):
return self.app_db_session\
.query(ub.Thumbnail)\
.filter(ub.Thumbnail.book_id == book_id)\
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\
return self.app_db_session \
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
.filter(ub.Thumbnail.entity_id == book_id) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.all()
def create_book_cover_thumbnail(self, book, resolution):
thumbnail = ub.Thumbnail()
thumbnail.book_id = book.id
thumbnail.type = constants.THUMBNAIL_TYPE_COVER
thumbnail.entity_id = book.id
thumbnail.format = 'jpeg'
thumbnail.resolution = resolution
@@ -118,7 +146,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
try:
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)
except Exception as 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)
img.resize(width=width, height=height, filter='lanczos')
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)
except Exception as ex:
# Bubble exception to calling function
@@ -158,26 +187,212 @@ class TaskGenerateCoverThumbnails(CalibreTask):
raise Exception('Book cover file not found')
with Image(filename=book_cover_filepath) as img:
height = self.get_thumbnail_height(thumbnail)
height = get_resize_height(thumbnail.resolution)
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.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)
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
def name(self):
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):
def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'):
super(TaskClearCoverThumbnailCache, self).__init__(task_message)
@@ -199,21 +414,22 @@ class TaskClearCoverThumbnailCache(CalibreTask):
self.app_db_session.remove()
def get_thumbnails_for_book(self, book_id):
return self.app_db_session\
.query(ub.Thumbnail)\
.filter(ub.Thumbnail.book_id == book_id)\
return self.app_db_session \
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
.filter(ub.Thumbnail.entity_id == book_id) \
.all()
def delete_thumbnail(self, thumbnail):
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:
self.log.info(u'Error deleting book thumbnail: ' + str(ex))
self._handleError(u'Error deleting book thumbnail: ' + str(ex))
def delete_all_thumbnails(self):
try:
self.cache.delete_cache_dir(fs.CACHE_TYPE_THUMBNAILS)
self.cache.delete_cache_dir(constants.CACHE_TYPE_THUMBNAILS)
except Exception as ex:
self.log.info(u'Error deleting book thumbnails: ' + str(ex))
self._handleError(u'Error deleting book thumbnails: ' + str(ex))