1
0
mirror of https://github.com/janeczku/calibre-web synced 2024-11-28 04:19:59 +00:00

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.py
This commit is contained in:
Ozzie Isaacs 2022-02-08 19:55:20 +01:00
commit 4a0dde0371
39 changed files with 1922 additions and 5326 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@ vendor/
# calibre-web # calibre-web
*.db *.db
*.log *.log
cps/cache
.idea/ .idea/
*.bak *.bak

6
cps.py
View File

@ -44,6 +44,7 @@ from cps.editbooks import editbook
from cps.remotelogin import remotelogin from cps.remotelogin import remotelogin
from cps.search_metadata import meta from cps.search_metadata import meta
from cps.error_handler import init_errorhandler from cps.error_handler import init_errorhandler
from cps.schedule import register_scheduled_tasks, register_startup_tasks
try: try:
from cps.kobo import kobo, get_kobo_activated from cps.kobo import kobo, get_kobo_activated
@ -79,6 +80,11 @@ def main():
app.register_blueprint(kobo_auth) app.register_blueprint(kobo_auth)
if oauth_available: if oauth_available:
app.register_blueprint(oauth) app.register_blueprint(oauth)
# Register scheduled tasks
register_scheduled_tasks()
register_startup_tasks()
success = web_server.start() success = web_server.start()
sys.exit(0 if success else 1) sys.exit(0 if success else 1)

View File

@ -159,6 +159,7 @@ def create_app():
config.store_calibre_uuid(calibre_db, db.Library_Id) config.store_calibre_uuid(calibre_db, db.Library_Id)
return app return app
@babel.localeselector @babel.localeselector
def get_locale(): def get_locale():
# if a user is logged in, use the locale from the user settings # if a user is logged in, use the locale from the user settings

View File

@ -40,11 +40,13 @@ from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
from sqlalchemy.sql.expression import func, or_, text from sqlalchemy.sql.expression import func, or_, text
from . import constants, logger, helper, services, cli from . import constants, logger, helper, services, cli
from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils, kobo_sync_status from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils, \
kobo_sync_status, schedule
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \
valid_email, check_username valid_email, check_username
from .gdriveutils import is_gdrive_ready, gdrive_support from .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config from .render_template import render_title_template, get_sidebar_config
from .services.worker import WorkerThread
from . import debug_info, _BABEL_TRANSLATIONS from . import debug_info, _BABEL_TRANSLATIONS
from functools import wraps from functools import wraps
@ -1635,6 +1637,45 @@ def update_mailsettings():
return edit_mailsettings() return edit_mailsettings()
@admi.route("/admin/scheduledtasks")
@login_required
@admin_required
def edit_scheduledtasks():
content = config.get_scheduled_task_settings()
return render_title_template("schedule_edit.html", config=content, title=_(u"Edit Scheduled Tasks Settings"))
@admi.route("/admin/scheduledtasks", methods=["POST"])
@login_required
@admin_required
def update_scheduledtasks():
to_save = request.form.to_dict()
_config_int(to_save, "schedule_start_time")
_config_int(to_save, "schedule_end_time")
_config_checkbox(to_save, "schedule_generate_book_covers")
_config_checkbox(to_save, "schedule_generate_series_covers")
try:
config.save()
flash(_(u"Scheduled tasks settings updated"), category="success")
# Cancel any running tasks
schedule.end_scheduled_tasks()
# Re-register tasks with new settings
schedule.register_scheduled_tasks()
except IntegrityError as ex:
ub.session.rollback()
log.error("An unknown error occurred while saving scheduled tasks settings")
flash(_(u"An unknown error occurred. Please try again later."), category="error")
except OperationalError:
ub.session.rollback()
log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
return edit_scheduledtasks()
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"]) @admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
@login_required @login_required
@admin_required @admin_required
@ -1911,3 +1952,13 @@ def extract_dynamic_field_from_filter(user, filtr):
def extract_user_identifier(user, filtr): def extract_user_identifier(user, filtr):
dynamic_field = extract_dynamic_field_from_filter(user, filtr) dynamic_field = extract_dynamic_field_from_filter(user, filtr)
return extract_user_data_from_field(user, dynamic_field) return extract_user_data_from_field(user, dynamic_field)
@admi.route("/ajax/canceltask", methods=['POST'])
@login_required
@admin_required
def cancel_task():
task_id = request.get_json().get('task_id', None)
worker = WorkerThread.get_instance()
worker.end_task(task_id)
return ""

View File

@ -141,6 +141,11 @@ class _Settings(_Base):
config_reverse_proxy_login_header_name = Column(String) config_reverse_proxy_login_header_name = Column(String)
config_allow_reverse_proxy_header_login = Column(Boolean, default=False) config_allow_reverse_proxy_header_login = Column(Boolean, default=False)
schedule_start_time = Column(Integer, default=4)
schedule_end_time = Column(Integer, default=6)
schedule_generate_book_covers = Column(Boolean, default=False)
schedule_generate_series_covers = Column(Boolean, default=False)
def __repr__(self): def __repr__(self):
return self.__class__.__name__ return self.__class__.__name__
@ -171,7 +176,6 @@ class _ConfigSQL(object):
if change: if change:
self.save() self.save()
def _read_from_storage(self): def _read_from_storage(self):
if self._settings is None: if self._settings is None:
log.debug("_ConfigSQL._read_from_storage") log.debug("_ConfigSQL._read_from_storage")
@ -255,6 +259,8 @@ class _ConfigSQL(object):
return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0) return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0)
or (self.mail_gmail_token != {} and self.mail_server_type == 1)) or (self.mail_gmail_token != {} and self.mail_server_type == 1))
def get_scheduled_task_settings(self):
return {k:v for k, v in self.__dict__.items() if k.startswith('schedule_')}
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None): def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
"""Possibly updates a field of this object. """Possibly updates a field of this object.
@ -290,7 +296,6 @@ class _ConfigSQL(object):
storage[k] = v storage[k] = v
return storage return storage
def load(self): def load(self):
'''Load all configuration values from the underlying storage.''' '''Load all configuration values from the underlying storage.'''
s = self._read_from_storage() # type: _Settings s = self._read_from_storage() # type: _Settings
@ -411,6 +416,7 @@ def autodetect_calibre_binary():
return element return element
return "" return ""
def autodetect_unrar_binary(): def autodetect_unrar_binary():
if sys.platform == "win32": if sys.platform == "win32":
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe", calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
@ -422,6 +428,7 @@ def autodetect_unrar_binary():
return element return element
return "" return ""
def autodetect_kepubify_binary(): def autodetect_kepubify_binary():
if sys.platform == "win32": if sys.platform == "win32":
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe", calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
@ -433,6 +440,7 @@ def autodetect_kepubify_binary():
return element return element
return "" return ""
def _migrate_database(session): def _migrate_database(session):
# make sure the table is created, if it does not exist # make sure the table is created, if it does not exist
_Base.metadata.create_all(session.bind) _Base.metadata.create_all(session.bind)

View File

@ -23,6 +23,9 @@ from sqlalchemy import __version__ as sql_version
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0]) sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0])
# APP_MODE - production, development, or test
APP_MODE = os.environ.get('APP_MODE', 'production')
# if installed via pip this variable is set to true (empty file with name .HOMEDIR present) # if installed via pip this variable is set to true (empty file with name .HOMEDIR present)
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR')) HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
@ -35,6 +38,10 @@ STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates') TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations') TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
# Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache
DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache')
CACHE_DIR = os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR)
if HOME_CONFIG: if HOME_CONFIG:
home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web") home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web")
if not os.path.exists(home_dir): if not os.path.exists(home_dir):
@ -162,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

@ -450,11 +450,11 @@ class CalibreDB():
""" """
self.session = None self.session = None
if self._init: if self._init:
self.initSession(expire_on_commit) self.init_session(expire_on_commit)
self.instances.add(self) self.instances.add(self)
def initSession(self, expire_on_commit=True): def init_session(self, expire_on_commit=True):
self.session = self.session_factory() self.session = self.session_factory()
self.session.expire_on_commit = expire_on_commit self.session.expire_on_commit = expire_on_commit
self.update_title_sort(self.config) self.update_title_sort(self.config)
@ -603,7 +603,7 @@ class CalibreDB():
autoflush=True, autoflush=True,
bind=cls.engine)) bind=cls.engine))
for inst in cls.instances: for inst in cls.instances:
inst.initSession() inst.init_session()
cls._init = True cls._init = True
return True return True
@ -720,7 +720,8 @@ class CalibreDB():
randm = self.session.query(Books) \ randm = self.session.query(Books) \
.filter(self.common_filters(allow_show_archived)) \ .filter(self.common_filters(allow_show_archived)) \
.order_by(func.random()) \ .order_by(func.random()) \
.limit(self.config.config_random_books).all() .limit(self.config.config_random_books) \
.all()
else: else:
randm = false() randm = false()
if join_archive_read: if join_archive_read:

View File

@ -686,6 +686,7 @@ def upload_cover(request, book):
abort(403) abort(403)
ret, message = helper.save_cover(requested_file, book.path) ret, message = helper.save_cover(requested_file, book.path)
if ret is True: if ret is True:
helper.clear_cover_thumbnail_cache(book.id)
return True return True
else: else:
flash(message, category="error") flash(message, category="error")
@ -809,6 +810,7 @@ def edit_book(book_id):
if result is True: if result is True:
book.has_cover = 1 book.has_cover = 1
modif_date = True modif_date = True
helper.clear_cover_thumbnail_cache(book.id)
else: else:
flash(error, category="error") flash(error, category="error")

96
cps/fs.py Normal file
View File

@ -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

View File

@ -34,7 +34,7 @@ from babel.units import format_unit
from flask import send_from_directory, make_response, redirect, abort, url_for from flask import send_from_directory, make_response, redirect, abort, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user from flask_login import current_user
from sqlalchemy.sql.expression import true, false, and_, text, func from sqlalchemy.sql.expression import true, false, and_, or_, text, func
from sqlalchemy.exc import InvalidRequestError, OperationalError from sqlalchemy.exc import InvalidRequestError, OperationalError
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@ -49,12 +49,14 @@ except ImportError:
from . import calibre_db, cli from . import calibre_db, cli
from .tasks.convert import TaskConvert from .tasks.convert import TaskConvert
from . import logger, config, get_locale, db, ub, kobo_sync_status from . import logger, config, get_locale, db, ub, kobo_sync_status, fs
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, STAT_ENDED, \
STAT_CANCELLED
from .tasks.mail import TaskEmail from .tasks.mail import TaskEmail
from .tasks.thumbnail import TaskClearCoverThumbnailCache
log = logger.create() log = logger.create()
@ -497,6 +499,7 @@ def upload_new_file_gdrive(book_id, first_author, renamed_author, title, title_d
return error return error
def update_dir_structure_gdrive(book_id, first_author, renamed_author): def update_dir_structure_gdrive(book_id, first_author, renamed_author):
error = False error = False
book = calibre_db.get_book(book_id) book = calibre_db.get_book(book_id)
@ -633,6 +636,7 @@ def uniq(inpt):
output.append(x) output.append(x)
return output return output
def check_email(email): def check_email(email):
email = valid_email(email) email = valid_email(email)
if ub.session.query(ub.User).filter(func.lower(ub.User.email) == email.lower()).first(): if ub.session.query(ub.User).filter(func.lower(ub.User.email) == email.lower()).first():
@ -679,6 +683,7 @@ def update_dir_structure(book_id,
def delete_book(book, calibrepath, book_format): def delete_book(book, calibrepath, book_format):
clear_cover_thumbnail_cache(book.id)
if config.config_use_google_drive: if config.config_use_google_drive:
return delete_book_gdrive(book, book_format) return delete_book_gdrive(book, book_format)
else: else:
@ -692,19 +697,29 @@ def get_cover_on_failure(use_generic_cover):
return None return None
def get_book_cover(book_id): def get_book_cover(book_id, resolution=None):
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
return get_book_cover_internal(book, use_generic_cover_on_failure=True) return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution)
def get_book_cover_with_uuid(book_uuid, def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True):
use_generic_cover_on_failure=True):
book = calibre_db.get_book_by_uuid(book_uuid) book = calibre_db.get_book_by_uuid(book_uuid)
return get_book_cover_internal(book, use_generic_cover_on_failure) return get_book_cover_internal(book, use_generic_cover_on_failure)
def get_book_cover_internal(book, use_generic_cover_on_failure): def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None):
if book and book.has_cover: if book and book.has_cover:
# Send the book cover thumbnail if it exists in cache
if resolution:
thumbnail = get_book_cover_thumbnail(book, 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)
# Send the book cover from Google Drive if configured
if config.config_use_google_drive: if config.config_use_google_drive:
try: try:
if not gd.is_gdrive_ready(): if not gd.is_gdrive_ready():
@ -718,6 +733,8 @@ def get_book_cover_internal(book, use_generic_cover_on_failure):
except Exception as ex: except Exception as ex:
log.debug_or_exception(ex) log.debug_or_exception(ex)
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure(use_generic_cover_on_failure)
# Send the book cover from the Calibre directory
else: else:
cover_file_path = os.path.join(config.config_calibre_dir, book.path) cover_file_path = os.path.join(config.config_calibre_dir, book.path)
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")): if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
@ -728,6 +745,56 @@ def get_book_cover_internal(book, use_generic_cover_on_failure):
return get_cover_on_failure(use_generic_cover_on_failure) return get_cover_on_failure(use_generic_cover_on_failure)
def get_book_cover_thumbnail(book, resolution):
if book and book.has_cover:
return ub.session \
.query(ub.Thumbnail) \
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \
.filter(ub.Thumbnail.entity_id == book.id) \
.filter(ub.Thumbnail.resolution == resolution) \
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
.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:
@ -920,12 +987,22 @@ def render_task_status(tasklist):
ret['status'] = _(u'Started') ret['status'] = _(u'Started')
elif task.stat == STAT_FINISH_SUCCESS: elif task.stat == STAT_FINISH_SUCCESS:
ret['status'] = _(u'Finished') ret['status'] = _(u'Finished')
elif task.stat == STAT_ENDED:
ret['status'] = _(u'Ended')
elif task.stat == STAT_CANCELLED:
ret['status'] = _(u'Cancelled')
else: else:
ret['status'] = _(u'Unknown Status') ret['status'] = _(u'Unknown Status')
ret['taskMessage'] = "{}: {}".format(_(task.name), task.message) ret['taskMessage'] = "{}: {}".format(_(task.name), task.message) if task.message else _(task.name)
ret['progress'] = "{} %".format(int(task.progress * 100)) ret['progress'] = "{} %".format(int(task.progress * 100))
ret['user'] = escape(user) # prevent xss ret['user'] = escape(user) # prevent xss
# Hidden fields
ret['id'] = task.id
ret['stat'] = task.stat
ret['is_cancellable'] = task.is_cancellable
renderedtasklist.append(ret) renderedtasklist.append(ret)
return renderedtasklist return renderedtasklist
@ -994,3 +1071,7 @@ def get_download_link(book_id, book_format, client):
return do_download_file(book, book_format, client, data1, headers) return do_download_file(book, book_format, client, data1, headers)
else: else:
abort(404) abort(404)
def clear_cover_thumbnail_cache(book_id):
WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id))

View File

@ -31,8 +31,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
jinjia = Blueprint('jinjia', __name__) jinjia = Blueprint('jinjia', __name__)
log = logger.create() log = logger.create()
@ -128,12 +127,55 @@ def formatseriesindex_filter(series_index):
return series_index return series_index
return 0 return 0
@jinjia.app_template_filter('escapedlink') @jinjia.app_template_filter('escapedlink')
def escapedlink_filter(url, text): def escapedlink_filter(url, text):
return "<a href='{}'>{}</a>".format(url, escape(text)) return "<a href='{}'>{}</a>".format(url, escape(text))
@jinjia.app_template_filter('uuidfilter') @jinjia.app_template_filter('uuidfilter')
def uuidfilter(var): 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')
def book_last_modified(book):
return str(int(book.last_modified.timestamp()))
@jinjia.app_template_filter('get_cover_srcset')
def get_cover_srcset(book):
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_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')
return ', '.join(srcset)

88
cps/schedule.py Normal file
View File

@ -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)

View File

@ -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()

View File

@ -37,6 +37,8 @@ STAT_WAITING = 0
STAT_FAIL = 1 STAT_FAIL = 1
STAT_STARTED = 2 STAT_STARTED = 2
STAT_FINISH_SUCCESS = 3 STAT_FINISH_SUCCESS = 3
STAT_ENDED = 4
STAT_CANCELLED = 5
# Only retain this many tasks in dequeued list # Only retain this many tasks in dequeued list
TASK_CLEANUP_TRIGGER = 20 TASK_CLEANUP_TRIGGER = 20
@ -51,7 +53,6 @@ def _get_main_thread():
raise Exception("main thread not found?!") raise Exception("main thread not found?!")
class ImprovedQueue(queue.Queue): class ImprovedQueue(queue.Queue):
def to_list(self): def to_list(self):
""" """
@ -61,12 +62,13 @@ class ImprovedQueue(queue.Queue):
with self.mutex: with self.mutex:
return list(self.queue) return list(self.queue)
# Class for all worker tasks in the background # Class for all worker tasks in the background
class WorkerThread(threading.Thread): class WorkerThread(threading.Thread):
_instance = None _instance = None
@classmethod @classmethod
def getInstance(cls): def get_instance(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = WorkerThread() cls._instance = WorkerThread()
return cls._instance return cls._instance
@ -83,12 +85,13 @@ class WorkerThread(threading.Thread):
@classmethod @classmethod
def add(cls, user, task): def add(cls, user, task):
ins = cls.getInstance() ins = cls.get_instance()
ins.num += 1 ins.num += 1
log.debug("Add Task for user: {} - {}".format(user, task)) username = user if user is not None else 'System'
log.debug("Add Task for user: {} - {}".format(username, task))
ins.queue.put(QueuedTask( ins.queue.put(QueuedTask(
num=ins.num, num=ins.num,
user=user, user=username,
added=datetime.now(), added=datetime.now(),
task=task, task=task,
)) ))
@ -144,8 +147,18 @@ class WorkerThread(threading.Thread):
# CalibreTask.start() should wrap all exceptions in it's own error handling # CalibreTask.start() should wrap all exceptions in it's own error handling
item.task.start(self) item.task.start(self)
# remove self_cleanup tasks from list
if item.task.self_cleanup:
self.dequeued.remove(item)
self.queue.task_done() self.queue.task_done()
def end_task(self, task_id):
ins = self.get_instance()
for __, __, __, task in ins.tasks:
if str(task.id) == str(task_id) and task.is_cancellable:
task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED
class CalibreTask: class CalibreTask:
__metaclass__ = abc.ABCMeta __metaclass__ = abc.ABCMeta
@ -158,10 +171,12 @@ class CalibreTask:
self.end_time = None self.end_time = None
self.message = message self.message = message
self.id = uuid.uuid4() self.id = uuid.uuid4()
self.self_cleanup = False
self._scheduled = False
@abc.abstractmethod @abc.abstractmethod
def run(self, worker_thread): def run(self, worker_thread):
"""Provides the caller some human-readable name for this class""" """The main entry-point for this task"""
raise NotImplementedError raise NotImplementedError
@abc.abstractmethod @abc.abstractmethod
@ -169,6 +184,11 @@ class CalibreTask:
"""Provides the caller some human-readable name for this class""" """Provides the caller some human-readable name for this class"""
raise NotImplementedError raise NotImplementedError
@abc.abstractmethod
def is_cancellable(self):
"""Does this task gracefully handle being cancelled (STAT_ENDED, STAT_CANCELLED)?"""
raise NotImplementedError
def start(self, *args): def start(self, *args):
self.start_time = datetime.now() self.start_time = datetime.now()
self.stat = STAT_STARTED self.stat = STAT_STARTED
@ -219,7 +239,7 @@ class CalibreTask:
We have a separate dictating this because there may be certain tasks that want to override this We have a separate dictating this because there may be certain tasks that want to override this
""" """
# By default, we're good to clean a task if it's "Done" # By default, we're good to clean a task if it's "Done"
return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL) return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL, STAT_ENDED, STAT_CANCELLED)
'''@progress.setter '''@progress.setter
def progress(self, x): def progress(self, x):
@ -229,6 +249,22 @@ class CalibreTask:
x = 0 x = 0
self._progress = x''' self._progress = x'''
@property
def self_cleanup(self):
return self._self_cleanup
@self_cleanup.setter
def self_cleanup(self, is_self_cleanup):
self._self_cleanup = is_self_cleanup
@property
def scheduled(self):
return self._scheduled
@scheduled.setter
def scheduled(self, is_scheduled):
self._scheduled = is_scheduled
def _handleError(self, error_message): def _handleError(self, error_message):
self.stat = STAT_FAIL self.stat = STAT_FAIL
self.progress = 1 self.progress = 1

View File

@ -5150,7 +5150,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
pointer-events: none pointer-events: none
} }
#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover { #DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, #cancelTaskModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover {
cursor: pointer cursor: pointer
} }
@ -5237,7 +5237,11 @@ body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > d
margin-bottom: 20px margin-bottom: 20px
} }
body.admin:not(.modal-open) .btn-default { body.admin > div.container-fluid div.scheduled_tasks_details {
margin-bottom: 20px
}
body.admin .btn-default {
margin-bottom: 10px margin-bottom: 10px
} }
@ -5468,7 +5472,7 @@ body.admin.modal-open .navbar {
z-index: 0 !important z-index: 0 !important
} }
#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal { #RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal, #cancelTaskModal {
top: 0; top: 0;
overflow: hidden; overflow: hidden;
padding-top: 70px; padding-top: 70px;
@ -5478,7 +5482,7 @@ body.admin.modal-open .navbar {
background: rgba(0, 0, 0, .5) background: rgba(0, 0, 0, .5)
} }
#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before { #RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before, #cancelTaskModal:before {
content: "\E208"; content: "\E208";
padding-right: 10px; padding-right: 10px;
display: block; display: block;
@ -5500,18 +5504,18 @@ body.admin.modal-open .navbar {
z-index: 99 z-index: 99
} }
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before { #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before {
-webkit-transform: translate(0, 0); -webkit-transform: translate(0, 0);
-ms-transform: translate(0, 0); -ms-transform: translate(0, 0);
transform: translate(0, 0) transform: translate(0, 0)
} }
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog { #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog {
width: 450px; width: 450px;
margin: auto margin: auto
} }
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content {
max-height: calc(100% - 90px); max-height: calc(100% - 90px);
-webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
box-shadow: 0 5px 15px rgba(0, 0, 0, .5); box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
@ -5522,7 +5526,7 @@ body.admin.modal-open .navbar {
width: 450px width: 450px
} }
#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header { #RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header {
padding: 15px 20px; padding: 15px 20px;
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
line-height: 1.71428571; line-height: 1.71428571;
@ -5535,7 +5539,7 @@ body.admin.modal-open .navbar {
text-align: left text-align: left
} }
#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before { #RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before {
padding-right: 10px; padding-right: 10px;
font-size: 18px; font-size: 18px;
color: #999; color: #999;
@ -5564,6 +5568,11 @@ body.admin.modal-open .navbar {
font-family: plex-icons-new, serif font-family: plex-icons-new, serif
} }
#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before {
content: "\EA6D";
font-family: plex-icons-new, serif
}
#RestartDialog > .modal-dialog > .modal-content > .modal-header:after { #RestartDialog > .modal-dialog > .modal-content > .modal-header:after {
content: "Restart Calibre-Web"; content: "Restart Calibre-Web";
display: inline-block; display: inline-block;
@ -5588,7 +5597,13 @@ body.admin.modal-open .navbar {
font-size: 20px font-size: 20px
} }
#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile { #cancelTaskModal > .modal-dialog > .modal-content > .modal-header:after {
content: "Delete Book";
display: inline-block;
font-size: 20px
}
#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile {
display: none display: none
} }
@ -5602,7 +5617,7 @@ body.admin.modal-open .navbar {
text-align: left text-align: left
} }
#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body { #ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body {
padding: 20px 20px 40px; padding: 20px 20px 40px;
font-size: 16px; font-size: 16px;
line-height: 1.6em; line-height: 1.6em;
@ -5612,7 +5627,7 @@ body.admin.modal-open .navbar {
text-align: left text-align: left
} }
#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p { #RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body > p {
padding: 20px 20px 0 0; padding: 20px 20px 0 0;
font-size: 16px; font-size: 16px;
line-height: 1.6em; line-height: 1.6em;
@ -5621,7 +5636,7 @@ body.admin.modal-open .navbar {
background: #282828 background: #282828
} }
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default { #RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default {
float: right; float: right;
z-index: 9; z-index: 9;
position: relative; position: relative;
@ -5669,6 +5684,18 @@ body.admin.modal-open .navbar {
border-radius: 3px border-radius: 3px
} }
#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger {
float: right;
z-index: 9;
position: relative;
margin: 0 0 0 10px;
min-width: 80px;
padding: 10px 18px;
font-size: 16px;
line-height: 1.33;
border-radius: 3px
}
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart) { #RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart) {
margin: 25px 0 0 10px margin: 25px 0 0 10px
} }
@ -5681,7 +5708,11 @@ body.admin.modal-open .navbar {
margin: 0 0 0 10px margin: 0 0 0 10px
} }
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover { #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default {
margin: 0 0 0 10px
}
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover {
background-color: hsla(0, 0%, 100%, .3) background-color: hsla(0, 0%, 100%, .3)
} }
@ -7303,11 +7334,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
background-color: transparent !important background-color: transparent !important
} }
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog { #RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog {
max-width: calc(100vw - 40px) max-width: calc(100vw - 40px)
} }
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content { #RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content {
max-width: calc(100vw - 40px); max-width: calc(100vw - 40px);
left: 0 left: 0
} }
@ -7457,7 +7488,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
padding: 30px 15px padding: 30px 15px
} }
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before { #RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before {
left: auto; left: auto;
right: 34px right: 34px
} }

View File

@ -15,7 +15,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
/* exported TableActions, RestrictionActions, EbookActions, responseHandler */ /* exported TableActions, RestrictionActions, EbookActions, TaskActions, responseHandler */
/* global getPath, confirmDialog */ /* global getPath, confirmDialog */
var selections = []; var selections = [];
@ -42,6 +42,24 @@ $(function() {
}, 1000); }, 1000);
} }
$("#cancel_task_confirm").click(function() {
//get data-id attribute of the clicked element
var taskId = $(this).data("task-id");
$.ajax({
method: "post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: window.location.pathname + "/../ajax/canceltask",
data: JSON.stringify({"task_id": taskId}),
});
});
//triggered when modal is about to be shown
$("#cancelTaskModal").on("show.bs.modal", function(e) {
//get data-id attribute of the clicked element and store in button
var taskId = $(e.relatedTarget).data("task-id");
$(e.currentTarget).find("#cancel_task_confirm").data("task-id", taskId);
});
$("#books-table").on("check.bs.table check-all.bs.table uncheck.bs.table uncheck-all.bs.table", $("#books-table").on("check.bs.table check-all.bs.table uncheck.bs.table uncheck-all.bs.table",
function (e, rowsAfter, rowsBefore) { function (e, rowsAfter, rowsBefore) {
var rows = rowsAfter; var rows = rowsAfter;
@ -581,6 +599,7 @@ function handle_header_buttons () {
$(".header_select").removeAttr("disabled"); $(".header_select").removeAttr("disabled");
} }
} }
/* Function for deleting domain restrictions */ /* Function for deleting domain restrictions */
function TableActions (value, row) { function TableActions (value, row) {
return [ return [
@ -618,6 +637,19 @@ function UserActions (value, row) {
].join(""); ].join("");
} }
/* Function for cancelling tasks */
function TaskActions (value, row) {
var cancellableStats = [0, 1, 2];
if (row.id && row.is_cancellable && cancellableStats.includes(row.stat)) {
return [
"<div class=\"task-cancel\" data-toggle=\"modal\" data-target=\"#cancelTaskModal\" data-task-id=\"" + row.id + "\" title=\"Cancel\">",
"<i class=\"glyphicon glyphicon-ban-circle\"></i>",
"</div>"
].join("");
}
return '';
}
/* Function for keeping checked rows */ /* Function for keeping checked rows */
function responseHandler(res) { function responseHandler(res) {
$.each(res.rows, function (i, row) { $.each(res.rows, function (i, row) {

View File

@ -273,3 +273,7 @@ class TaskConvert(CalibreTask):
def __str__(self): def __str__(self):
return "Convert {} {}".format(self.bookid, self.kindle_mail) return "Convert {} {}".format(self.bookid, self.kindle_mail)
@property
def is_cancellable(self):
return False

53
cps/tasks/database.py Normal file
View File

@ -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

View File

@ -266,5 +266,9 @@ class TaskEmail(CalibreTask):
def name(self): def name(self):
return "E-mail" return "E-mail"
@property
def is_cancellable(self):
return False
def __str__(self): def __str__(self):
return "E-mail {}, {}".format(self.name, self.subject) return "E-mail {}, {}".format(self.name, self.subject)

472
cps/tasks/thumbnail.py Normal file
View File

@ -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

View File

@ -36,3 +36,7 @@ class TaskUpload(CalibreTask):
def __str__(self): def __str__(self):
return "Upload {}".format(self.book_title) return "Upload {}".format(self.book_title)
@property
def is_cancellable(self):
return False

View File

@ -160,6 +160,31 @@
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<h2>{{_('Scheduled Tasks')}}</h2>
<div class="col-xs-12 col-sm-12 scheduled_tasks_details">
<div class="row">
<div class="col-xs-6 col-sm-3">{{_('Time at which tasks start to run')}}</div>
<div class="col-xs-6 col-sm-3">{{config.schedule_start_time}}:00</div>
</div>
<div class="row">
<div class="col-xs-6 col-sm-3">{{_('Time at which tasks stop running')}}</div>
<div class="col-xs-6 col-sm-3">{{config.schedule_end_time}}:00</div>
</div>
<div class="row">
<div class="col-xs-6 col-sm-3">{{_('Generate book cover thumbnails')}}</div>
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_book_covers) }}</div>
</div>
<div class="row">
<div class="col-xs-6 col-sm-3">{{_('Generate series cover thumbnails')}}</div>
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_series_covers) }}</div>
</div>
</div>
<a class="btn btn-default scheduledtasks" id="admin_edit_scheduled_tasks" href="{{url_for('admin.edit_scheduledtasks')}}">{{_('Edit Scheduled Tasks Settings')}}</a>
</div>
</div>
<div class="row form-group"> <div class="row form-group">
<h2>{{_('Administration')}}</h2> <h2>{{_('Administration')}}</h2>
<a class="btn btn-default" id="debug" href="{{url_for('admin.download_debug')}}">{{_('Download Debug Package')}}</a> <a class="btn btn-default" id="debug" href="{{url_for('admin.download_debug')}}">{{_('Download Debug Package')}}</a>
@ -167,6 +192,8 @@
</div> </div>
<div class="row form-group"> <div class="row form-group">
<div class="btn btn-default" id="restart_database" data-toggle="modal" data-target="#StatusDialog">{{_('Reconnect Calibre Database')}}</div> <div class="btn btn-default" id="restart_database" data-toggle="modal" data-target="#StatusDialog">{{_('Reconnect Calibre Database')}}</div>
</div>
<div class="row form-group">
<div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div> <div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div>
<div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div> <div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div>
</div> </div>

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" title="{{entry.title}}"> <span class="img" title="{{entry.title}}">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" /> {{ image.book_cover(entry, alt=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

@ -3,7 +3,8 @@
{% 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">
<img id="detailcover" title="{{book.title}}" src="{{ url_for('web.get_cover', book_id=book.id, edit=1|uuidfilter) }}" alt="{{ book.title }}"/> <!-- Always use full-sized image for the book edit page -->
<img id="detailcover" title="{{book.title}}" src="{{url_for('web.get_cover', book_id=book.id, resolution='og', c=book|last_modified)}}" />
</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,8 @@
<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">
<img id="detailcover" title="{{entry.title}}" src="{{ url_for('web.get_cover', book_id=entry.id, edit=1|uuidfilter) }}" alt="{{ entry.title }}" /> <!-- Always use full-sized image for the detail page -->
<img id="detailcover" title="{{entry.title}}" src="{{url_for('web.get_cover', book_id=entry.id, resolution='og', c=entry|last_modified)}}" />
</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,3 +1,4 @@
{% 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">
@ -9,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" title="{{entry.title}}"> <span class="img" title="{{entry.title}}">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" /> {{ 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,3 +1,4 @@
{% import 'image.html' as image %}
<div class="container-fluid"> <div class="container-fluid">
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>

View File

@ -1,3 +1,4 @@
{% 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>
@ -27,7 +28,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" title="{{entry[0].series[0].name}}"> <span class="img" title="{{entry[0].series[0].name}}">
<img src="{{ url_for('web.get_cover', book_id=entry[3]) }}" alt="{{ entry[0].series[0].name }}"/> {{ image.series(entry[0].series[0], alt=entry[0].series[0].name|shortentitle) }}
<span class="badge">{{entry.count}}</span> <span class="badge">{{entry.count}}</span>
</span> </span>
</a> </a>

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

@ -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 %}

View File

@ -1,3 +1,4 @@
{% 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() %}
@ -9,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" title="{{ entry.title }}"> <span class="img" title="{{ entry.title }}">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" /> {{ 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>
@ -91,7 +92,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" title="{{ entry.title }}"> <span class="img" title="{{ entry.title }}">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}"/> {{ 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,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 %}
{% import 'image.html' as image %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ g.user.locale }}"> <html lang="{{ g.user.locale }}">
<head> <head>

View File

@ -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 %}

View File

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

View File

@ -1,3 +1,4 @@
{% import 'image.html' as image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="discover"> <div class="discover">
@ -34,7 +35,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" title="{{entry.title}}" > <span class="img" title="{{entry.title}}" >
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" /> {{ 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

@ -16,6 +16,9 @@
<th data-halign="right" data-align="right" data-field="progress" data-sortable="true" data-sorter="elementSorter">{{_('Progress')}}</th> <th data-halign="right" data-align="right" data-field="progress" data-sortable="true" data-sorter="elementSorter">{{_('Progress')}}</th>
<th data-halign="right" data-align="right" data-field="runtime" data-sortable="true" data-sort-name="rt">{{_('Run Time')}}</th> <th data-halign="right" data-align="right" data-field="runtime" data-sortable="true" data-sort-name="rt">{{_('Run Time')}}</th>
<th data-halign="right" data-align="right" data-field="starttime" data-sortable="true" data-sort-name="id">{{_('Start Time')}}</th> <th data-halign="right" data-align="right" data-field="starttime" data-sortable="true" data-sort-name="id">{{_('Start Time')}}</th>
{% if g.user.role_admin() %}
<th data-halign="right" data-align="right" data-formatter="TaskActions" data-switchable="false">{{_('Actions')}}</th>
{% endif %}
<th data-field="id" data-visible="false"></th> <th data-field="id" data-visible="false"></th>
<th data-field="rt" data-visible="false"></th> <th data-field="rt" data-visible="false"></th>
</tr> </tr>
@ -23,6 +26,30 @@
</table> </table>
</div> </div>
{% endblock %} {% endblock %}
{% block modal %}
{{ delete_book() }}
{% if g.user.role_admin() %}
<div class="modal fade" id="cancelTaskModal" role="dialog" aria-labelledby="metaCancelTaskLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-center">
<span>{{_('Are you really sure?')}}</span>
</div>
<div class="modal-body text-center">
<p>
<span>{{_('This task will be cancelled. Any progress made by this task will be saved.')}}</span>
<span>{{_('If this is a scheduled task, it will be re-ran during the next scheduled time.')}}</span>
</p>
</div>
<div class="modal-footer">
<input type="button" class="btn btn-danger" value="{{_('Ok')}}" name="cancel_task_confirm" id="cancel_task_confirm" data-dismiss="modal">
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block js %} {% block js %}
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-locale-all.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-locale-all.min.js') }}"></script>

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import atexit
import os import os
import sys import sys
import datetime import datetime
@ -510,6 +511,28 @@ class RemoteAuthToken(Base):
return '<Token %r>' % self.id return '<Token %r>' % self.id
def filename(context):
file_format = context.get_current_parameters()['format']
if file_format == 'jpeg':
return context.get_current_parameters()['uuid'] + '.jpg'
else:
return context.get_current_parameters()['uuid'] + '.' + file_format
class Thumbnail(Base):
__tablename__ = 'thumbnail'
id = Column(Integer, primary_key=True)
entity_id = Column(Integer)
uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True)
format = Column(String, default='jpeg')
type = Column(SmallInteger, default=constants.THUMBNAIL_TYPE_COVER)
resolution = Column(SmallInteger, default=constants.COVER_THUMBNAIL_SMALL)
filename = Column(String, default=filename)
generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow())
expiration = Column(DateTime, nullable=True)
# Add missing tables during migration of database # Add missing tables during migration of database
def add_missing_tables(engine, _session): def add_missing_tables(engine, _session):
if not engine.dialect.has_table(engine.connect(), "book_read_link"): if not engine.dialect.has_table(engine.connect(), "book_read_link"):
@ -524,6 +547,8 @@ def add_missing_tables(engine, _session):
KoboStatistics.__table__.create(bind=engine) KoboStatistics.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "archived_book"): if not engine.dialect.has_table(engine.connect(), "archived_book"):
ArchivedBook.__table__.create(bind=engine) ArchivedBook.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "thumbnail"):
Thumbnail.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "registration"): if not engine.dialect.has_table(engine.connect(), "registration"):
Registration.__table__.create(bind=engine) Registration.__table__.create(bind=engine)
with engine.connect() as conn: with engine.connect() as conn:
@ -829,6 +854,16 @@ def init_db(app_db_path):
sys.exit(3) sys.exit(3)
def get_new_session_instance():
new_engine = create_engine(u'sqlite:///{0}'.format(cli.settingspath), echo=False)
new_session = scoped_session(sessionmaker())
new_session.configure(bind=new_engine)
atexit.register(lambda: new_session.remove() if new_session else True)
return new_session
def dispose(): def dispose():
global session global session

View File

@ -48,8 +48,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, kobo_sync_status from . import calibre_db, kobo_sync_status
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, \
edit_book_read_status edit_book_read_status
from .pagination import Pagination from .pagination import Pagination
@ -128,7 +128,7 @@ def viewer_required(f):
@web.route("/ajax/emailstat") @web.route("/ajax/emailstat")
@login_required @login_required
def get_email_status_json(): def get_email_status_json():
tasks = WorkerThread.getInstance().tasks tasks = WorkerThread.get_instance().tasks
return jsonify(render_task_status(tasks)) return jsonify(render_task_status(tasks))
@ -761,6 +761,7 @@ def books_table():
return render_title_template('book_table.html', title=_(u"Books List"), cc=cc, page="book_table", return render_title_template('book_table.html', title=_(u"Books List"), cc=cc, page="book_table",
visiblility=visibility) visiblility=visibility)
@web.route("/ajax/listbooks") @web.route("/ajax/listbooks")
@login_required @login_required
def list_books(): def list_books():
@ -858,6 +859,7 @@ def list_books():
response.headers["Content-Type"] = "application/json; charset=utf-8" response.headers["Content-Type"] = "application/json; charset=utf-8"
return response return response
@web.route("/ajax/table_settings", methods=['POST']) @web.route("/ajax/table_settings", methods=['POST'])
@login_required @login_required
def update_table_settings(): def update_table_settings():
@ -937,6 +939,7 @@ def publisher_list():
charlist = calibre_db.session.query(func.upper(func.substr(db.Publishers.name, 1, 1)).label('char')) \ charlist = calibre_db.session.query(func.upper(func.substr(db.Publishers.name, 1, 1)).label('char')) \
.join(db.books_publishers_link).join(db.Books).filter(calibre_db.common_filters()) \ .join(db.books_publishers_link).join(db.Books).filter(calibre_db.common_filters()) \
.group_by(func.upper(func.substr(db.Publishers.name, 1, 1))).all() .group_by(func.upper(func.substr(db.Publishers.name, 1, 1))).all()
return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=charlist, return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=charlist,
title=_(u"Publishers"), page="publisherlist", data="publisher", order=order_no) title=_(u"Publishers"), page="publisherlist", data="publisher", order=order_no)
else: else:
@ -1066,7 +1069,7 @@ def category_list():
@login_required @login_required
def get_tasks_status(): def get_tasks_status():
# if current user admin, show all email, otherwise only own emails # if current user admin, show all email, otherwise only own emails
tasks = WorkerThread.getInstance().tasks tasks = WorkerThread.get_instance().tasks
answer = render_task_status(tasks) answer = render_task_status(tasks)
return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks") return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks")
@ -1395,7 +1398,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
pagination=pagination, pagination=pagination,
entries=entries, entries=entries,
result_count=result_count, result_count=result_count,
title=_(u"Advanced Search"), page="advsearch", title=_(u"Advanced Search"),
page="advsearch",
order=order[1]) order=order[1])
@ -1411,14 +1415,38 @@ def advanced_search_form():
@web.route("/cover/<int:book_id>") @web.route("/cover/<int:book_id>")
@web.route("/cover/<int:book_id>/<string:resolution>")
@login_required_if_no_ano @login_required_if_no_ano
def get_cover(book_id): def get_cover(book_id, resolution=None):
return get_book_cover(book_id) 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")
def get_robots(): def get_robots():
return send_from_directory(constants.STATIC_DIR, "robots.txt") return send_from_directory(constants.STATIC_DIR, "robots.txt")
@web.route("/show/<int:book_id>/<book_format>", defaults={'anyname': 'None'}) @web.route("/show/<int:book_id>/<book_format>", defaults={'anyname': 'None'})
@web.route("/show/<int:book_id>/<book_format>/<anyname>") @web.route("/show/<int:book_id>/<book_format>/<anyname>")
@login_required_if_no_ano @login_required_if_no_ano

View File

@ -1,3 +1,4 @@
APScheduler>=3.6.3,<3.8.0
Babel>=1.3,<3.0 Babel>=1.3,<3.0
Flask-Babel>=0.11.1,<2.1.0 Flask-Babel>=0.11.1,<2.1.0
Flask-Login>=0.3.2,<0.5.1 Flask-Login>=0.3.2,<0.5.1

File diff suppressed because it is too large Load Diff