Merge remote-tracking branch 'cover_images/thumbnails' into cover_thumbnail
# Conflicts: # cps/admin.py # cps/config_sql.py # cps/helper.py # cps/tasks/upload.py # cps/updater.py # cps/web.pypull/2262/head^2
commit
4a0dde0371
@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 mmonkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import division, print_function, unicode_literals
|
||||
from . import logger
|
||||
from .constants import CACHE_DIR
|
||||
from os import makedirs, remove
|
||||
from os.path import isdir, isfile, join
|
||||
from shutil import rmtree
|
||||
|
||||
|
||||
class FileSystem:
|
||||
_instance = None
|
||||
_cache_dir = CACHE_DIR
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(FileSystem, cls).__new__(cls)
|
||||
cls.log = logger.create()
|
||||
return cls._instance
|
||||
|
||||
def get_cache_dir(self, cache_type=None):
|
||||
if not isdir(self._cache_dir):
|
||||
try:
|
||||
makedirs(self._cache_dir)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to create path {self._cache_dir} (Permission denied).')
|
||||
return False
|
||||
|
||||
path = join(self._cache_dir, cache_type)
|
||||
if cache_type and not isdir(path):
|
||||
try:
|
||||
makedirs(path)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to create path {path} (Permission denied).')
|
||||
return False
|
||||
|
||||
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):
|
||||
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):
|
||||
path = self.get_cache_file_path(filename, cache_type)
|
||||
return isfile(path)
|
||||
|
||||
def delete_cache_dir(self, cache_type=None):
|
||||
if not cache_type and isdir(self._cache_dir):
|
||||
try:
|
||||
rmtree(self._cache_dir)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to delete path {self._cache_dir} (Permission denied).')
|
||||
return False
|
||||
|
||||
path = join(self._cache_dir, cache_type)
|
||||
if cache_type and isdir(path):
|
||||
try:
|
||||
rmtree(path)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to delete path {path} (Permission denied).')
|
||||
return False
|
||||
|
||||
def delete_cache_file(self, filename, cache_type=None):
|
||||
path = self.get_cache_file_path(filename, cache_type)
|
||||
if isfile(path):
|
||||
try:
|
||||
remove(path)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to delete path {path} (Permission denied).')
|
||||
return False
|
@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 mmonkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from . import config, constants
|
||||
from .services.background_scheduler import BackgroundScheduler
|
||||
from .tasks.database import TaskReconnectDatabase
|
||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails
|
||||
from .services.worker import WorkerThread
|
||||
|
||||
|
||||
def get_scheduled_tasks(reconnect=True):
|
||||
tasks = list()
|
||||
|
||||
# Reconnect Calibre database (metadata.db)
|
||||
if reconnect:
|
||||
tasks.append(lambda: TaskReconnectDatabase())
|
||||
|
||||
# Generate all missing book cover thumbnails
|
||||
if config.schedule_generate_book_covers:
|
||||
tasks.append(lambda: TaskGenerateCoverThumbnails())
|
||||
|
||||
# Generate all missing series thumbnails
|
||||
if config.schedule_generate_series_covers:
|
||||
tasks.append(lambda: TaskGenerateSeriesThumbnails())
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def end_scheduled_tasks():
|
||||
worker = WorkerThread.get_instance()
|
||||
for __, __, __, task in worker.tasks:
|
||||
if task.scheduled and task.is_cancellable:
|
||||
worker.end_task(task.id)
|
||||
|
||||
|
||||
def register_scheduled_tasks():
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
if scheduler:
|
||||
# Remove all existing jobs
|
||||
scheduler.remove_all_jobs()
|
||||
|
||||
start = config.schedule_start_time
|
||||
end = config.schedule_end_time
|
||||
|
||||
# Register scheduled tasks
|
||||
if start != end:
|
||||
scheduler.schedule_tasks(tasks=get_scheduled_tasks(), trigger='cron', hour=start)
|
||||
scheduler.schedule(func=end_scheduled_tasks, trigger='cron', hour=end)
|
||||
|
||||
# Kick-off tasks, if they should currently be running
|
||||
if should_task_be_running(start, end):
|
||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
||||
|
||||
|
||||
def register_startup_tasks():
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
if scheduler:
|
||||
start = config.schedule_start_time
|
||||
end = config.schedule_end_time
|
||||
|
||||
# Run scheduled tasks immediately for development and testing
|
||||
# Ignore tasks that should currently be running, as these will be added when registering scheduled tasks
|
||||
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, end):
|
||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
||||
|
||||
|
||||
def should_task_be_running(start, end):
|
||||
now = datetime.datetime.now().hour
|
||||
return (start < end and start <= now < end) or (end < start <= now or now < end)
|
@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 mmonkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import atexit
|
||||
|
||||
from .. import logger
|
||||
from .worker import WorkerThread
|
||||
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
|
||||
use_APScheduler = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
use_APScheduler = False
|
||||
log = logger.create()
|
||||
log.info('APScheduler not found. Unable to schedule tasks.')
|
||||
|
||||
|
||||
class BackgroundScheduler:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if not use_APScheduler:
|
||||
return False
|
||||
|
||||
if cls._instance is None:
|
||||
cls._instance = super(BackgroundScheduler, cls).__new__(cls)
|
||||
cls.log = logger.create()
|
||||
cls.scheduler = BScheduler()
|
||||
cls.scheduler.start()
|
||||
|
||||
atexit.register(lambda: cls.scheduler.shutdown())
|
||||
|
||||
return cls._instance
|
||||
|
||||
def schedule(self, func, trigger, **trigger_args):
|
||||
if use_APScheduler:
|
||||
return self.scheduler.add_job(func=func, trigger=trigger, **trigger_args)
|
||||
|
||||
# Expects a lambda expression for the task
|
||||
def schedule_task(self, task, user=None, trigger='cron', **trigger_args):
|
||||
if use_APScheduler:
|
||||
def scheduled_task():
|
||||
worker_task = task()
|
||||
worker_task.scheduled = True
|
||||
WorkerThread.add(user, worker_task)
|
||||
return self.schedule(func=scheduled_task, trigger=trigger, **trigger_args)
|
||||
|
||||
# Expects a list of lambda expressions for the tasks
|
||||
def schedule_tasks(self, tasks, user=None, trigger='cron', **trigger_args):
|
||||
if use_APScheduler:
|
||||
for task in tasks:
|
||||
self.schedule_task(task, user=user, trigger=trigger, **trigger_args)
|
||||
|
||||
# Expects a lambda expression for the task
|
||||
def schedule_task_immediately(self, task, user=None):
|
||||
if use_APScheduler:
|
||||
def immediate_task():
|
||||
WorkerThread.add(user, task())
|
||||
return self.schedule(func=immediate_task, trigger='date')
|
||||
|
||||
# Expects a list of lambda expressions for the tasks
|
||||
def schedule_tasks_immediately(self, tasks, user=None):
|
||||
if use_APScheduler:
|
||||
for task in tasks:
|
||||
self.schedule_task_immediately(task, user)
|
||||
|
||||
# Remove all jobs
|
||||
def remove_all_jobs(self):
|
||||
self.scheduler.remove_all_jobs()
|
@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 mmonkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import division, print_function, unicode_literals
|
||||
|
||||
from cps import config, logger
|
||||
from cps.services.worker import CalibreTask
|
||||
|
||||
try:
|
||||
from urllib.request import urlopen
|
||||
except ImportError as e:
|
||||
from urllib2 import urlopen
|
||||
|
||||
|
||||
class TaskReconnectDatabase(CalibreTask):
|
||||
def __init__(self, task_message=u'Reconnecting Calibre database'):
|
||||
super(TaskReconnectDatabase, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.listen_address = config.get_config_ipaddress()
|
||||
self.listen_port = config.config_port
|
||||
|
||||
def run(self, worker_thread):
|
||||
address = self.listen_address if self.listen_address else 'localhost'
|
||||
port = self.listen_port if self.listen_port else 8083
|
||||
|
||||
try:
|
||||
urlopen('http://' + address + ':' + str(port) + '/reconnect')
|
||||
self._handleSuccess()
|
||||
except Exception as ex:
|
||||
self._handleError(u'Unable to reconnect Calibre database: ' + str(ex))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "Reconnect Database"
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return False
|
@ -0,0 +1,472 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 monkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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, STAT_CANCELLED, STAT_ENDED
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func, text, or_
|
||||
|
||||
try:
|
||||
from urllib.request import urlopen
|
||||
except ImportError as e:
|
||||
from urllib2 import urlopen
|
||||
|
||||
try:
|
||||
from wand.image import Image
|
||||
use_IM = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
use_IM = False
|
||||
|
||||
|
||||
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):
|
||||
def __init__(self, task_message=''):
|
||||
super(TaskGenerateCoverThumbnails, 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
|
||||
]
|
||||
|
||||
def run(self, worker_thread):
|
||||
if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||
self.message = 'Scanning Books'
|
||||
books_with_covers = self.get_books_with_covers()
|
||||
count = len(books_with_covers)
|
||||
|
||||
total_generated = 0
|
||||
for i, book in enumerate(books_with_covers):
|
||||
generated = 0
|
||||
book_cover_thumbnails = self.get_book_cover_thumbnails(book.id)
|
||||
|
||||
# Generate new thumbnails for missing covers
|
||||
resolutions = list(map(lambda t: t.resolution, book_cover_thumbnails))
|
||||
missing_resolutions = list(set(self.resolutions).difference(resolutions))
|
||||
for resolution in missing_resolutions:
|
||||
generated += 1
|
||||
self.create_book_cover_thumbnail(book, resolution)
|
||||
|
||||
# Replace outdated or missing thumbnails
|
||||
for thumbnail in book_cover_thumbnails:
|
||||
if book.last_modified > thumbnail.generated_at:
|
||||
generated += 1
|
||||
self.update_book_cover_thumbnail(book, thumbnail)
|
||||
|
||||
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
|
||||
generated += 1
|
||||
self.update_book_cover_thumbnail(book, thumbnail)
|
||||
|
||||
# Increment the progress
|
||||
self.progress = (1.0 / count) * i
|
||||
|
||||
if generated > 0:
|
||||
total_generated += generated
|
||||
self.message = u'Generated {0} cover thumbnails'.format(total_generated)
|
||||
|
||||
# Check if job has been cancelled or ended
|
||||
if self.stat == STAT_CANCELLED:
|
||||
self.log.info(f'GenerateCoverThumbnails task has been cancelled.')
|
||||
return
|
||||
|
||||
if self.stat == STAT_ENDED:
|
||||
self.log.info(f'GenerateCoverThumbnails task has been ended.')
|
||||
return
|
||||
|
||||
if total_generated == 0:
|
||||
self.self_cleanup = True
|
||||
|
||||
self._handleSuccess()
|
||||
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) \
|
||||
.all()
|
||||
|
||||
def get_book_cover_thumbnails(self, 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) \
|
||||
.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.type = constants.THUMBNAIL_TYPE_COVER
|
||||
thumbnail.entity_id = book.id
|
||||
thumbnail.format = 'jpeg'
|
||||
thumbnail.resolution = resolution
|
||||
|
||||
self.app_db_session.add(thumbnail)
|
||||
try:
|
||||
self.app_db_session.commit()
|
||||
self.generate_book_thumbnail(book, 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_book_cover_thumbnail(self, book, 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_book_thumbnail(book, 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_book_thumbnail(self, book, thumbnail):
|
||||
if book and thumbnail:
|
||||
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:
|
||||
height = get_resize_height(thumbnail.resolution)
|
||||
if img.height > height:
|
||||
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,
|
||||
constants.CACHE_TYPE_THUMBNAILS)
|
||||
img.save(filename=filename)
|
||||
except Exception as ex:
|
||||
# Bubble exception to calling function
|
||||
self.log.info(u'Error generating thumbnail file: ' + str(ex))
|
||||
raise ex
|
||||
finally:
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
else:
|
||||
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:
|
||||
height = get_resize_height(thumbnail.resolution)
|
||||
if img.height > height:
|
||||
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, constants.CACHE_TYPE_THUMBNAILS)
|
||||
img.save(filename=filename)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return 'GenerateCoverThumbnails'
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return True
|
||||
|
||||
|
||||
class TaskGenerateSeriesThumbnails(CalibreTask):
|
||||
def __init__(self, task_message=''):
|
||||
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,
|
||||
]
|
||||
|
||||
def run(self, worker_thread):
|
||||
if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||
self.message = 'Scanning Series'
|
||||
all_series = self.get_series_with_four_plus_books()
|
||||
count = len(all_series)
|
||||
|
||||
total_generated = 0
|
||||
for i, series in enumerate(all_series):
|
||||
generated = 0
|
||||
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):
|
||||
generated += 1
|
||||
self.update_series_thumbnail(series_books, thumbnail)
|
||||
|
||||
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
|
||||
generated += 1
|
||||
self.update_series_thumbnail(series_books, thumbnail)
|
||||
|
||||
# Increment the progress
|
||||
self.progress = (1.0 / count) * i
|
||||
|
||||
if generated > 0:
|
||||
total_generated += generated
|
||||
self.message = u'Generated {0} series thumbnails'.format(total_generated)
|
||||
|
||||
# Check if job has been cancelled or ended
|
||||
if self.stat == STAT_CANCELLED:
|
||||
self.log.info(f'GenerateSeriesThumbnails task has been cancelled.')
|
||||
return
|
||||
|
||||
if self.stat == STAT_ENDED:
|
||||
self.log.info(f'GenerateSeriesThumbnails task has been ended.')
|
||||
return
|
||||
|
||||
if total_generated == 0:
|
||||
self.self_cleanup = True
|
||||
|
||||
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):
|
||||
# Get the last four books in the series based on series_index
|
||||
books = sorted(series_books, key=lambda b: float(b.series_index), reverse=True)[: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:
|
||||
if stream is not None:
|
||||
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 'GenerateSeriesThumbnails'
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return True
|
||||
|
||||
|
||||
class TaskClearCoverThumbnailCache(CalibreTask):
|
||||
def __init__(self, book_id, task_message=u'Clearing cover thumbnail cache'):
|
||||
super(TaskClearCoverThumbnailCache, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.book_id = book_id
|
||||
self.app_db_session = ub.get_new_session_instance()
|
||||
self.cache = fs.FileSystem()
|
||||
|
||||
def run(self, worker_thread):
|
||||
if self.app_db_session:
|
||||
if self.book_id:
|
||||
thumbnails = self.get_thumbnails_for_book(self.book_id)
|
||||
for thumbnail in thumbnails:
|
||||
self.delete_thumbnail(thumbnail)
|
||||
|
||||
self._handleSuccess()
|
||||
self.app_db_session.remove()
|
||||
|
||||
def get_thumbnails_for_book(self, 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):
|
||||
thumbnail.expiration = datetime.utcnow()
|
||||
|
||||
try:
|
||||
self.app_db_session.commit()
|
||||
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))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return 'ThumbnailsClear'
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return False
|
@ -0,0 +1,20 @@
|
||||
{% macro book_cover(book, alt=None) -%}
|
||||
{%- set image_title = book.title if book.title else book.name -%}
|
||||
{%- 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) }}"
|
||||
alt="{{ image_alt }}"
|
||||
/>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro series(series, 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='day'|cache_timestamp) }}"
|
||||
alt="{{ book_title }}"
|
||||
/>
|
||||
{%- endmacro %}
|
@ -0,0 +1,39 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block header %}
|
||||
<link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<h1>{{title}}</h1>
|
||||
<form role="form" class="col-md-10 col-lg-6" method="POST" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="schedule_start_time">{{_('Time at which tasks start to run')}}</label>
|
||||
<select name="schedule_start_time" id="schedule_start_time" class="form-control">
|
||||
{% for n in range(24) %}
|
||||
<option value="{{n}}" {% if config.schedule_start_time == n %}selected{% endif %}>{{n}}{{_(':00')}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule_end_time">{{_('Time at which tasks stop running')}}</label>
|
||||
<select name="schedule_end_time" id="schedule_end_time" class="form-control">
|
||||
{% for n in range(24) %}
|
||||
<option value="{{n}}" {% if config.schedule_end_time == n %}selected{% endif %}>{{n}}{{_(':00')}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="schedule_generate_book_covers" name="schedule_generate_book_covers" {% if config.schedule_generate_book_covers %}checked{% endif %}>
|
||||
<label for="schedule_generate_book_covers">{{_('Generate Book Cover Thumbnails')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="schedule_generate_series_covers" name="schedule_generate_series_covers" {% if config.schedule_generate_series_covers %}checked{% endif %}>
|
||||
<label for="schedule_generate_series_covers">{{_('Generate Series Cover Thumbnails')}}</label>
|
||||
</div>
|
||||
<button type="submit" name="submit" value="submit" class="btn btn-default">{{_('Save')}}</button>
|
||||
<a href="{{ url_for('admin.admin') }}" id="email_back" class="btn btn-default">{{_('Cancel')}}</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue