Display thumbnails on the frontend, generate thumbnails from google drive

This commit is contained in:
mmonkey 2020-12-20 03:11:21 -06:00
parent 21fce9a5b5
commit e48bdf9d5a
19 changed files with 133 additions and 53 deletions

5
cps.py
View File

@ -43,6 +43,7 @@ from cps.gdrive import gdrive
from cps.editbooks import editbook from cps.editbooks import editbook
from cps.remotelogin import remotelogin from cps.remotelogin import remotelogin
from cps.error_handler import init_errorhandler from cps.error_handler import init_errorhandler
from cps.schedule import register_jobs
try: try:
from cps.kobo import kobo, get_kobo_activated from cps.kobo import kobo, get_kobo_activated
@ -78,6 +79,10 @@ 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 jobs
register_jobs()
success = web_server.start() success = web_server.start()
sys.exit(0 if success else 1) sys.exit(0 if success else 1)

View File

@ -36,8 +36,6 @@ from flask_principal import Principal
from . import config_sql, logger, cache_buster, cli, ub, db from . import config_sql, logger, cache_buster, cli, ub, db
from .reverseproxy import ReverseProxied from .reverseproxy import ReverseProxied
from .server import WebServer from .server import WebServer
from .services.background_scheduler import BackgroundScheduler
from .tasks.thumbnail import TaskThumbnail
mimetypes.init() mimetypes.init()
@ -117,10 +115,6 @@ def create_app():
config.config_goodreads_api_secret, config.config_goodreads_api_secret,
config.config_use_goodreads) config.config_use_goodreads)
scheduler = BackgroundScheduler()
# Generate 100 book cover thumbnails every 5 minutes
scheduler.add_task(user=None, task=lambda: TaskThumbnail(config=config, limit=100), trigger='interval', minutes=5)
return app return app

View File

@ -52,7 +52,7 @@ except ImportError:
from . import calibre_db from . import calibre_db
from .tasks.convert import TaskConvert from .tasks.convert import TaskConvert
from . import logger, config, get_locale, db, ub from . import logger, config, get_locale, db, thumbnails, ub
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
from .subproc_wrapper import process_wait from .subproc_wrapper import process_wait
@ -538,24 +538,27 @@ 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=1):
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=1, disable_thumbnail=False):
if book and book.has_cover: if book and book.has_cover:
# if thumbnails.cover_thumbnail_exists_for_book(book):
# thumbnail = ub.session.query(ub.Thumbnail).filter(ub.Thumbnail.book_id == book.id).first() # Send the book cover thumbnail if it exists in cache
# return send_from_directory(thumbnails.get_thumbnail_cache_dir(), thumbnail.filename) if not disable_thumbnail:
# else: thumbnail = get_book_cover_thumbnail(book, resolution)
# WorkerThread.add(None, TaskThumbnail(book, _(u'Generating cover thumbnail for: ' + book.title))) if thumbnail:
if os.path.isfile(thumbnails.get_thumbnail_cache_path(thumbnail)):
return send_from_directory(thumbnails.get_thumbnail_cache_dir(), 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():
@ -569,6 +572,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")):
@ -579,6 +584,16 @@ 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=1):
if book and book.has_cover:
return ub.session\
.query(ub.Thumbnail)\
.filter(ub.Thumbnail.book_id == book.id)\
.filter(ub.Thumbnail.resolution == resolution)\
.filter(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:

34
cps/schedule.py Normal file
View File

@ -0,0 +1,34 @@
# -*- 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 .services.background_scheduler import BackgroundScheduler
from .tasks.thumbnail import TaskThumbnail
log = logger.create()
def register_jobs():
scheduler = BackgroundScheduler()
# Generate 100 book cover thumbnails every 5 minutes
scheduler.add_task(user=None, task=lambda: TaskThumbnail(limit=100), trigger='interval', minutes=5)
# TODO: validate thumbnail scheduled task

View File

@ -19,11 +19,13 @@
from __future__ import division, print_function, unicode_literals from __future__ import division, print_function, unicode_literals
import os import os
from cps import db, logger, ub from cps import config, db, gdriveutils, logger, ub
from cps.constants import CACHE_DIR as _CACHE_DIR from cps.constants import CACHE_DIR as _CACHE_DIR
from cps.services.worker import CalibreTask from cps.services.worker import CalibreTask
from cps.thumbnails import THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy import func from sqlalchemy import func
from urllib.request import urlopen
try: try:
from wand.image import Image from wand.image import Image
@ -31,14 +33,10 @@ try:
except (ImportError, RuntimeError) as e: except (ImportError, RuntimeError) as e:
use_IM = False use_IM = False
THUMBNAIL_RESOLUTION_1X = 1.0
THUMBNAIL_RESOLUTION_2X = 2.0
class TaskThumbnail(CalibreTask): class TaskThumbnail(CalibreTask):
def __init__(self, config, limit=100, task_message=u'Generating cover thumbnails'): def __init__(self, limit=100, task_message=u'Generating cover thumbnails'):
super(TaskThumbnail, self).__init__(task_message) super(TaskThumbnail, self).__init__(task_message)
self.config = config
self.limit = limit self.limit = limit
self.log = logger.create() self.log = logger.create()
self.app_db_session = ub.get_new_session_instance() self.app_db_session = ub.get_new_session_instance()
@ -114,17 +112,39 @@ class TaskThumbnail(CalibreTask):
def generate_book_thumbnail(self, book, thumbnail): def generate_book_thumbnail(self, book, thumbnail):
if book and thumbnail: if book and thumbnail:
if self.config.config_use_google_drive: if config.config_use_google_drive:
self.log.info('google drive thumbnail') if not gdriveutils.is_gdrive_ready():
else: raise Exception('Google Drive is configured but not ready')
book_cover_filepath = os.path.join(self.config.config_calibre_dir, book.path, 'cover.jpg')
if os.path.isfile(book_cover_filepath): web_content_link = gdriveutils.get_cover_via_gdrive(book.path)
with Image(filename=book_cover_filepath) as img: 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 = self.get_thumbnail_height(thumbnail) height = self.get_thumbnail_height(thumbnail)
if img.height > height: if img.height > height:
width = self.get_thumbnail_width(height, img) width = self.get_thumbnail_width(height, img)
img.resize(width=width, height=height, filter='lanczos') img.resize(width=width, height=height, filter='lanczos')
img.save(filename=self.get_thumbnail_cache_path(thumbnail)) img.save(filename=self.get_thumbnail_cache_path(thumbnail))
except Exception as ex:
# Bubble exception to calling function
raise ex
finally:
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 = self.get_thumbnail_height(thumbnail)
if img.height > height:
width = self.get_thumbnail_width(height, img)
img.resize(width=width, height=height, filter='lanczos')
img.save(filename=self.get_thumbnail_cache_path(thumbnail))
def get_thumbnail_height(self, thumbnail): def get_thumbnail_height(self, thumbnail):
return int(225 * thumbnail.resolution) return int(225 * thumbnail.resolution)

View File

@ -36,7 +36,7 @@
<div id="books" class="col-sm-3 col-lg-2 col-xs-6 book"> <div id="books" class="col-sm-3 col-lg-2 col-xs-6 book">
<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) }}">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" /> {{ book_cover_image(entry.id, entry.title) }}
</a> </a>
</div> </div>
<div class="meta"> <div class="meta">

View File

@ -0,0 +1,8 @@
{% macro book_cover_image(book_id, book_title) -%}
<img
srcset="{{ url_for('web.get_cover', book_id=book_id, resolution=1) }} 1x,
{{ url_for('web.get_cover', book_id=book_id, resolution=2) }} 2x"
src="{{ url_for('web.get_cover', book_id=book_id) }}"
alt="{{ book_title }}"
/>
{%- endmacro %}

View File

@ -1,9 +1,11 @@
{% from 'book_cover.html' import book_cover_image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
{% 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 src="{{ url_for('web.get_cover', book_id=book.id, edit=1|uuidfilter) }}" alt="{{ book.title }}"/> {{ book_cover_image(book.id, book.title) }}
<!-- <img src="{{ url_for('web.get_cover', book_id=book.id, edit=1|uuidfilter) }}" alt="{{ book.title }}"/>-->
</div> </div>
{% if g.user.role_delete_books() %} {% if g.user.role_delete_books() %}
<div class="text-center"> <div class="text-center">

View File

@ -4,7 +4,8 @@
<div class="row"> <div class="row">
<div class="col-sm-3 col-lg-3 col-xs-5"> <div class="col-sm-3 col-lg-3 col-xs-5">
<div class="cover"> <div class="cover">
<img src="{{ url_for('web.get_cover', book_id=entry.id, edit=1|uuidfilter) }}" alt="{{ entry.title }}" /> {{ book_cover_image(entry.id, entry.title) }}
<!-- <img src="{{ url_for('web.get_cover', book_id=entry.id, edit=1|uuidfilter) }}" alt="{{ entry.title }}" />-->
</div> </div>
</div> </div>
<div class="col-sm-9 col-lg-9 book-meta"> <div class="col-sm-9 col-lg-9 book-meta">

View File

@ -1,3 +1,4 @@
{% from 'book_cover.html' import book_cover_image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="discover load-more"> <div class="discover load-more">
@ -8,7 +9,7 @@
<div class="cover"> <div class="cover">
{% 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">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" /> {{ book_cover_image(entry.id, entry.title) }}
</a> </a>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,3 +1,4 @@
{% from 'book_cover.html' import book_cover_image %}
<div class="container-fluid"> <div class="container-fluid">
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>

View File

@ -1,3 +1,4 @@
{% from 'book_cover.html' import book_cover_image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<h1 class="{{page}}">{{_(title)}}</h1> <h1 class="{{page}}">{{_(title)}}</h1>
@ -28,7 +29,7 @@
<div class="col-sm-3 col-lg-2 col-xs-6 book sortable" {% if entry[0].sort %}data-name="{{entry[0].series[0].name}}"{% endif %} data-id="{% if entry[0].series[0].name %}{{entry[0].series[0].name}}{% endif %}"> <div class="col-sm-3 col-lg-2 col-xs-6 book sortable" {% if entry[0].sort %}data-name="{{entry[0].series[0].name}}"{% endif %} data-id="{% if entry[0].series[0].name %}{{entry[0].series[0].name}}{% endif %}">
<div class="cover"> <div class="cover">
<a href="{{url_for('web.books_list', data=data, sort_param='new', book_id=entry[0].series[0].id )}}"> <a href="{{url_for('web.books_list', data=data, sort_param='new', book_id=entry[0].series[0].id )}}">
<img src="{{ url_for('web.get_cover', book_id=entry[0].id) }}" alt="{{ entry[0].name }}"/> {{ book_cover_image(entry[0].id, entry[0].name) }}
<span class="badge">{{entry.count}}</span> <span class="badge">{{entry.count}}</span>
</a> </a>
</div> </div>

View File

@ -1,3 +1,4 @@
{% from 'book_cover.html' import book_cover_image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
{% if g.user.show_detail_random() %} {% if g.user.show_detail_random() %}
@ -8,7 +9,7 @@
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books_rand"> <div class="col-sm-3 col-lg-2 col-xs-6 book" id="books_rand">
<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">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" /> {{ book_cover_image(entry.id, entry.title) }}
</a> </a>
</div> </div>
<div class="meta"> <div class="meta">
@ -82,7 +83,7 @@
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books"> <div class="col-sm-3 col-lg-2 col-xs-6 book" id="books">
<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">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}"/> {{ book_cover_image(entry.id, entry.title) }}
</a> </a>
</div> </div>
<div class="meta"> <div class="meta">

View File

@ -1,4 +1,5 @@
{% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal %} {% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal %}
{% from 'book_cover.html' import book_cover_image %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ g.user.locale }}"> <html lang="{{ g.user.locale }}">
<head> <head>

View File

@ -1,3 +1,4 @@
{% from 'book_cover.html' import book_cover_image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="discover"> <div class="discover">
@ -43,7 +44,7 @@
<div class="cover"> <div class="cover">
{% 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">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" /> {{ book_cover_image(entry.id, entry.title) }}
</a> </a>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,3 +1,4 @@
{% from 'book_cover.html' import book_cover_image %}
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="discover"> <div class="discover">
@ -30,7 +31,7 @@
<div class="col-sm-3 col-lg-2 col-xs-6 book"> <div class="col-sm-3 col-lg-2 col-xs-6 book">
<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">
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" /> {{ book_cover_image(entry.id, entry.title) }}
</a> </a>
</div> </div>
<div class="meta"> <div class="meta">

View File

@ -21,13 +21,11 @@ import os
from . import logger, ub from . import logger, ub
from .constants import CACHE_DIR as _CACHE_DIR from .constants import CACHE_DIR as _CACHE_DIR
from .services.worker import WorkerThread
from .tasks.thumbnail import TaskThumbnail
from datetime import datetime from datetime import datetime
THUMBNAIL_RESOLUTION_1X = 1.0 THUMBNAIL_RESOLUTION_1X = 1
THUMBNAIL_RESOLUTION_2X = 2.0 THUMBNAIL_RESOLUTION_2X = 2
log = logger.create() log = logger.create()
@ -35,17 +33,14 @@ log = logger.create()
def get_thumbnail_cache_dir(): def get_thumbnail_cache_dir():
if not os.path.isdir(_CACHE_DIR): if not os.path.isdir(_CACHE_DIR):
os.makedirs(_CACHE_DIR) os.makedirs(_CACHE_DIR)
if not os.path.isdir(os.path.join(_CACHE_DIR, 'thumbnails')): if not os.path.isdir(os.path.join(_CACHE_DIR, 'thumbnails')):
os.makedirs(os.path.join(_CACHE_DIR, 'thumbnails')) os.makedirs(os.path.join(_CACHE_DIR, 'thumbnails'))
return os.path.join(_CACHE_DIR, 'thumbnails') return os.path.join(_CACHE_DIR, 'thumbnails')
def get_thumbnail_cache_path(thumbnail): def get_thumbnail_cache_path(thumbnail):
if thumbnail: if thumbnail:
return os.path.join(get_thumbnail_cache_dir(), thumbnail.filename) return os.path.join(get_thumbnail_cache_dir(), thumbnail.filename)
return None return None

View File

@ -40,7 +40,7 @@ except ImportError:
oauth_support = False oauth_support = False
from sqlalchemy import create_engine, exc, exists, event from sqlalchemy import create_engine, exc, exists, event
from sqlalchemy import Column, ForeignKey from sqlalchemy import Column, ForeignKey
from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON, Numeric from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@ -442,7 +442,7 @@ class Thumbnail(Base):
book_id = Column(Integer) book_id = Column(Integer)
uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True) uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True)
format = Column(String, default='jpeg') format = Column(String, default='jpeg')
resolution = Column(Numeric(precision=2, scale=1, asdecimal=False), default=1.0) resolution = Column(SmallInteger, default=1)
expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30)) expiration = Column(DateTime, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(days=30))
@hybrid_property @hybrid_property

View File

@ -1171,14 +1171,17 @@ def advanced_search_form():
@web.route("/cover/<int:book_id>") @web.route("/cover/<int:book_id>")
@web.route("/cover/<int:book_id>/<int:resolution>")
@login_required_if_no_ano @login_required_if_no_ano
def get_cover(book_id): def get_cover(book_id, resolution=1):
return get_book_cover(book_id) return get_book_cover(book_id, 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
@ -1205,7 +1208,6 @@ def serve_book(book_id, book_format, anyname):
return send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + book_format) return send_from_directory(os.path.join(config.config_calibre_dir, book.path), data.name + "." + book_format)
@web.route("/download/<int:book_id>/<book_format>", defaults={'anyname': 'None'}) @web.route("/download/<int:book_id>/<book_format>", defaults={'anyname': 'None'})
@web.route("/download/<int:book_id>/<book_format>/<anyname>") @web.route("/download/<int:book_id>/<book_format>/<anyname>")
@login_required_if_no_ano @login_required_if_no_ano
@ -1387,9 +1389,6 @@ def logout():
return redirect(url_for('web.login')) return redirect(url_for('web.login'))
# ################################### Users own configuration ######################################################### # ################################### Users own configuration #########################################################