mirror of
				https://github.com/janeczku/calibre-web
				synced 2025-10-30 23:03:02 +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:
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -23,6 +23,7 @@ vendor/ | |||||||
| # calibre-web | # calibre-web | ||||||
| *.db | *.db | ||||||
| *.log | *.log | ||||||
|  | cps/cache | ||||||
|  |  | ||||||
| .idea/ | .idea/ | ||||||
| *.bak | *.bak | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								cps.py
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								cps.py
									
									
									
									
									
								
							| @@ -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) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
							
								
								
									
										53
									
								
								cps/admin.py
									
									
									
									
									
								
							
							
						
						
									
										53
									
								
								cps/admin.py
									
									
									
									
									
								
							| @@ -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 "" | ||||||
|   | |||||||
| @@ -134,13 +134,18 @@ class _Settings(_Base): | |||||||
|     config_calibre = Column(String) |     config_calibre = Column(String) | ||||||
|     config_rarfile_location = Column(String, default=None) |     config_rarfile_location = Column(String, default=None) | ||||||
|     config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD)) |     config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD)) | ||||||
|     config_unicode_filename =Column(Boolean, default=False) |     config_unicode_filename = Column(Boolean, default=False) | ||||||
|  |  | ||||||
|     config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE) |     config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE) | ||||||
|  |  | ||||||
|     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) | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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: | ||||||
|   | |||||||
| @@ -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
									
								
							
							
						
						
									
										96
									
								
								cps/fs.py
									
									
									
									
									
										Normal 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 | ||||||
							
								
								
									
										101
									
								
								cps/helper.py
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								cps/helper.py
									
									
									
									
									
								
							| @@ -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)) | ||||||
|   | |||||||
| @@ -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
									
								
							
							
						
						
									
										88
									
								
								cps/schedule.py
									
									
									
									
									
										Normal 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) | ||||||
							
								
								
									
										84
									
								
								cps/services/background_scheduler.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								cps/services/background_scheduler.py
									
									
									
									
									
										Normal 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() | ||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -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) { | ||||||
|   | |||||||
| @@ -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
									
								
							
							
						
						
									
										53
									
								
								cps/tasks/database.py
									
									
									
									
									
										Normal 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 | ||||||
| @@ -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
									
								
							
							
						
						
									
										472
									
								
								cps/tasks/thumbnail.py
									
									
									
									
									
										Normal 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 | ||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -160,15 +160,42 @@ | |||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|     <div class="row form-group"> |   <div class="row"> | ||||||
|     <h2>{{_('Administration')}}</h2> |     <div class="col"> | ||||||
|       <a class="btn btn-default" id="debug" href="{{url_for('admin.download_debug')}}">{{_('Download Debug Package')}}</a> |       <h2>{{_('Scheduled Tasks')}}</h2> | ||||||
|       <a class="btn btn-default" id="logfile" href="{{url_for('admin.view_logfile')}}">{{_('View Logs')}}</a> |         <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> | ||||||
|       <div class="btn btn-default" id="restart_database" data-toggle="modal" data-target="#StatusDialog">{{_('Reconnect Calibre Database')}}</div> |  | ||||||
|       <div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div> |   <div class="row form-group"> | ||||||
|       <div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div> |     <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="logfile" href="{{url_for('admin.view_logfile')}}">{{_('View Logs')}}</a> | ||||||
|  |   </div> | ||||||
|  |   <div class="row form-group"> | ||||||
|  |     <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_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div class="row"> |   <div class="row"> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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"> | ||||||
|   | |||||||
| @@ -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"> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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
									
								
							
							
						
						
									
										20
									
								
								cps/templates/image.html
									
									
									
									
									
										Normal 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 %} | ||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								cps/templates/schedule_edit.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								cps/templates/schedule_edit.html
									
									
									
									
									
										Normal 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 %} | ||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
							
								
								
									
										35
									
								
								cps/ub.py
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								cps/ub.py
									
									
									
									
									
								
							| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								cps/web.py
									
									
									
									
									
								
							
							
						
						
									
										42
									
								
								cps/web.py
									
									
									
									
									
								
							| @@ -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 | ||||||
|   | |||||||
| @@ -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
											
										
									
								
							
		Reference in New Issue
	
	Block a user
	 Ozzie Isaacs
					Ozzie Isaacs