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:
		
						commit
						4a0dde0371
					
				
							
								
								
									
										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
											
										
									
								
							
		Loading…
	
		Reference in New Issue
	
	Block a user