mirror of
https://github.com/janeczku/calibre-web
synced 2024-11-24 18:47:23 +00:00
Refactored startup for compatibility with pyinstaller 5.0
This commit is contained in:
parent
db03fb3edd
commit
9410b47144
77
cps.py
77
cps.py
@ -2,7 +2,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2012-2019 OzzieIsaacs
|
# Copyright (C) 2022 OzzieIsaacs
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@ -17,72 +17,19 @@
|
|||||||
# 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 sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Are we running from commandline?
|
||||||
|
if __package__ == '':
|
||||||
|
# Add local path to sys.path so we can import cps
|
||||||
|
path = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
# Insert local directories into path
|
from cps.main import main as _main
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor'))
|
|
||||||
|
|
||||||
|
|
||||||
from cps import create_app
|
|
||||||
from cps import web_server
|
|
||||||
from cps.opds import opds
|
|
||||||
from cps.web import web
|
|
||||||
from cps.jinjia import jinjia
|
|
||||||
from cps.about import about
|
|
||||||
from cps.shelf import shelf
|
|
||||||
from cps.admin import admi
|
|
||||||
from cps.gdrive import gdrive
|
|
||||||
from cps.editbooks import EditBook
|
|
||||||
from cps.remotelogin import remotelogin
|
|
||||||
from cps.search_metadata import meta
|
|
||||||
from cps.error_handler import init_errorhandler
|
|
||||||
from cps.schedule import register_scheduled_tasks, register_startup_tasks
|
|
||||||
|
|
||||||
try:
|
|
||||||
from cps.kobo import kobo, get_kobo_activated
|
|
||||||
from cps.kobo_auth import kobo_auth
|
|
||||||
kobo_available = get_kobo_activated()
|
|
||||||
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
|
||||||
kobo_available = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
from cps.oauth_bb import oauth
|
|
||||||
oauth_available = True
|
|
||||||
except ImportError:
|
|
||||||
oauth_available = False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
init_errorhandler()
|
|
||||||
|
|
||||||
app.register_blueprint(web)
|
|
||||||
app.register_blueprint(opds)
|
|
||||||
app.register_blueprint(jinjia)
|
|
||||||
app.register_blueprint(about)
|
|
||||||
app.register_blueprint(shelf)
|
|
||||||
app.register_blueprint(admi)
|
|
||||||
app.register_blueprint(remotelogin)
|
|
||||||
app.register_blueprint(meta)
|
|
||||||
app.register_blueprint(gdrive)
|
|
||||||
app.register_blueprint(EditBook)
|
|
||||||
if kobo_available:
|
|
||||||
app.register_blueprint(kobo)
|
|
||||||
app.register_blueprint(kobo_auth)
|
|
||||||
if oauth_available:
|
|
||||||
app.register_blueprint(oauth)
|
|
||||||
|
|
||||||
# Register scheduled tasks
|
|
||||||
register_scheduled_tasks() # ToDo only reconnect if reconnect is enabled
|
|
||||||
register_startup_tasks()
|
|
||||||
|
|
||||||
success = web_server.start()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
_main()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,8 +19,6 @@
|
|||||||
#
|
#
|
||||||
# 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/>.
|
||||||
__package__ = "cps"
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@ -28,15 +26,23 @@ import mimetypes
|
|||||||
from babel import Locale as LC
|
from babel import Locale as LC
|
||||||
from babel import negotiate_locale
|
from babel import negotiate_locale
|
||||||
from babel.core import UnknownLocaleError
|
from babel.core import UnknownLocaleError
|
||||||
from flask import Flask, request, g
|
from flask import request, g
|
||||||
|
from flask import Flask
|
||||||
from .MyLoginManager import MyLoginManager
|
from .MyLoginManager import MyLoginManager
|
||||||
from flask_babel import Babel
|
from flask_babel import Babel
|
||||||
from flask_principal import Principal
|
from flask_principal import Principal
|
||||||
|
|
||||||
from . import config_sql, logger, cache_buster, cli, ub, db
|
from . import config_sql
|
||||||
|
from . import logger
|
||||||
|
from . import cache_buster
|
||||||
|
from .cli import CliParameter
|
||||||
|
from .constants import CONFIG_DIR
|
||||||
|
from . import ub, db
|
||||||
from .reverseproxy import ReverseProxied
|
from .reverseproxy import ReverseProxied
|
||||||
from .server import WebServer
|
from .server import WebServer
|
||||||
from .dep_check import dependency_check
|
from .dep_check import dependency_check
|
||||||
|
from . import services
|
||||||
|
from .updater import Updater
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import lxml
|
import lxml
|
||||||
@ -50,6 +56,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
wtf_present = False
|
wtf_present = False
|
||||||
|
|
||||||
|
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||||
mimetypes.add_type('application/epub+zip', '.epub')
|
mimetypes.add_type('application/epub+zip', '.epub')
|
||||||
@ -81,37 +88,55 @@ app.config.update(
|
|||||||
|
|
||||||
|
|
||||||
lm = MyLoginManager()
|
lm = MyLoginManager()
|
||||||
lm.login_view = 'web.login'
|
|
||||||
lm.anonymous_user = ub.Anonymous
|
|
||||||
lm.session_protection = 'strong'
|
|
||||||
|
|
||||||
if wtf_present:
|
|
||||||
csrf = CSRFProtect()
|
|
||||||
csrf.init_app(app)
|
|
||||||
else:
|
|
||||||
csrf = None
|
|
||||||
|
|
||||||
ub.init_db(cli.settings_path)
|
|
||||||
# pylint: disable=no-member
|
|
||||||
config = config_sql.load_configuration(ub.session)
|
|
||||||
|
|
||||||
web_server = WebServer()
|
|
||||||
|
|
||||||
babel = Babel()
|
babel = Babel()
|
||||||
_BABEL_TRANSLATIONS = set()
|
_BABEL_TRANSLATIONS = set()
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
config = config_sql._ConfigSQL()
|
||||||
|
|
||||||
from . import services
|
cli_param = CliParameter()
|
||||||
|
|
||||||
db.CalibreDB.update_config(config)
|
|
||||||
db.CalibreDB.setup_db(config.config_calibre_dir, cli.settings_path)
|
|
||||||
|
|
||||||
|
if wtf_present:
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
else:
|
||||||
|
csrf = None
|
||||||
|
|
||||||
calibre_db = db.CalibreDB()
|
calibre_db = db.CalibreDB()
|
||||||
|
|
||||||
|
web_server = WebServer()
|
||||||
|
|
||||||
|
updater_thread = Updater()
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
lm.login_view = 'web.login'
|
||||||
|
lm.anonymous_user = ub.Anonymous
|
||||||
|
lm.session_protection = 'strong'
|
||||||
|
|
||||||
|
if csrf:
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
|
cli_param.init()
|
||||||
|
|
||||||
|
ub.init_db(os.path.join(CONFIG_DIR, "app.db"), cli_param.user_credentials)
|
||||||
|
|
||||||
|
# ub.init_db(os.path.join(CONFIG_DIR, "app.db"))
|
||||||
|
# pylint: disable=no-member
|
||||||
|
config_sql.load_configuration(config, ub.session, cli_param)
|
||||||
|
|
||||||
|
db.CalibreDB.update_config(config)
|
||||||
|
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||||
|
calibre_db.init_db()
|
||||||
|
|
||||||
|
updater_thread.init_updater(config, web_server)
|
||||||
|
# Perform dry run of updater and exit afterwards
|
||||||
|
if cli_param.dry_run:
|
||||||
|
updater_thread.dry_run()
|
||||||
|
sys.exit(0)
|
||||||
|
updater_thread.start()
|
||||||
|
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
log.info(
|
log.info(
|
||||||
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***')
|
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***')
|
||||||
@ -156,7 +181,7 @@ def create_app():
|
|||||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||||
config.config_goodreads_api_secret,
|
config.config_goodreads_api_secret,
|
||||||
config.config_use_goodreads)
|
config.config_use_goodreads)
|
||||||
config.store_calibre_uuid(calibre_db, db.LibraryId)
|
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@ -179,11 +204,8 @@ def get_locale():
|
|||||||
return negotiate_locale(preferred or ['en'], _BABEL_TRANSLATIONS)
|
return negotiate_locale(preferred or ['en'], _BABEL_TRANSLATIONS)
|
||||||
|
|
||||||
|
|
||||||
from .updater import Updater
|
'''@babel.timezoneselector
|
||||||
updater_thread = Updater()
|
def get_timezone():
|
||||||
|
user = getattr(g, 'user', None)
|
||||||
|
return user.timezone if user else None'''
|
||||||
|
|
||||||
# Perform dry run of updater and exit afterwards
|
|
||||||
if cli.dry_run:
|
|
||||||
updater_thread.dry_run()
|
|
||||||
sys.exit(0)
|
|
||||||
updater_thread.start()
|
|
||||||
|
@ -1254,7 +1254,7 @@ def _db_configuration_update_helper():
|
|||||||
if not calibre_db.setup_db(to_save['config_calibre_dir'], ub.app_DB_path):
|
if not calibre_db.setup_db(to_save['config_calibre_dir'], ub.app_DB_path):
|
||||||
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'),
|
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'),
|
||||||
gdrive_error)
|
gdrive_error)
|
||||||
config.store_calibre_uuid(calibre_db, db.LibraryId)
|
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||||
# if db changed -> delete shelfs, delete download books, delete read books, kobo sync...
|
# if db changed -> delete shelfs, delete download books, delete read books, kobo sync...
|
||||||
if db_change:
|
if db_change:
|
||||||
log.info("Calibre Database changed, all Calibre-Web info related to old Database gets deleted")
|
log.info("Calibre Database changed, all Calibre-Web info related to old Database gets deleted")
|
||||||
|
111
cps/cli.py
111
cps/cli.py
@ -31,96 +31,99 @@ def version_info():
|
|||||||
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
|
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
|
||||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
|
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
|
||||||
|
|
||||||
|
class CliParameter(object):
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Calibre Web is a web app'
|
def init(self):
|
||||||
' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py')
|
self.arg_parser()
|
||||||
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
|
||||||
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
|
def arg_parser(self):
|
||||||
parser.add_argument('-c', metavar='path',
|
parser = argparse.ArgumentParser(description='Calibre Web is a web app'
|
||||||
|
' providing a interface for browsing, reading and downloading eBooks\n',
|
||||||
|
prog='cps.py')
|
||||||
|
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
||||||
|
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
|
||||||
|
parser.add_argument('-c', metavar='path',
|
||||||
help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile')
|
help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile')
|
||||||
parser.add_argument('-k', metavar='path',
|
parser.add_argument('-k', metavar='path',
|
||||||
help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile')
|
help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile')
|
||||||
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
|
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
|
||||||
version=version_info())
|
version=version_info())
|
||||||
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
||||||
parser.add_argument('-s', metavar='user:pass', help='Sets specific username to new password and exits Calibre-Web')
|
parser.add_argument('-s', metavar='user:pass',
|
||||||
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
|
help='Sets specific username to new password and exits Calibre-Web')
|
||||||
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
|
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
|
||||||
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
|
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
|
||||||
|
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
|
||||||
'and exits Calibre-Web')
|
'and exits Calibre-Web')
|
||||||
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
|
self.settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
|
||||||
gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)
|
self.gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)
|
||||||
|
|
||||||
if os.path.isdir(settings_path):
|
if os.path.isdir(self.settings_path):
|
||||||
settings_path = os.path.join(settings_path, DEFAULT_SETTINGS_FILE)
|
self.settings_path = os.path.join(self.settings_path, DEFAULT_SETTINGS_FILE)
|
||||||
|
|
||||||
if os.path.isdir(gd_path):
|
if os.path.isdir(self.gd_path):
|
||||||
gd_path = os.path.join(gd_path, DEFAULT_GDRIVE_FILE)
|
self.gd_path = os.path.join(self.gd_path, DEFAULT_GDRIVE_FILE)
|
||||||
|
|
||||||
|
|
||||||
# handle and check parameter for ssl encryption
|
# handle and check parameter for ssl encryption
|
||||||
certfilepath = None
|
self.certfilepath = None
|
||||||
keyfilepath = None
|
self.keyfilepath = None
|
||||||
if args.c:
|
if args.c:
|
||||||
if os.path.isfile(args.c):
|
if os.path.isfile(args.c):
|
||||||
certfilepath = args.c
|
self.certfilepath = args.c
|
||||||
else:
|
else:
|
||||||
print("Certfile path is invalid. Exiting...")
|
print("Certfile path is invalid. Exiting...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.c == "":
|
if args.c == "":
|
||||||
certfilepath = ""
|
self.certfilepath = ""
|
||||||
|
|
||||||
if args.k:
|
if args.k:
|
||||||
if os.path.isfile(args.k):
|
if os.path.isfile(args.k):
|
||||||
keyfilepath = args.k
|
self.keyfilepath = args.k
|
||||||
else:
|
else:
|
||||||
print("Keyfile path is invalid. Exiting...")
|
print("Keyfile path is invalid. Exiting...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if (args.k and not args.c) or (not args.k and args.c):
|
if (args.k and not args.c) or (not args.k and args.c):
|
||||||
print("Certfile and Keyfile have to be used together. Exiting...")
|
print("Certfile and Keyfile have to be used together. Exiting...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.k == "":
|
if args.k == "":
|
||||||
keyfilepath = ""
|
self.keyfilepath = ""
|
||||||
|
|
||||||
|
# dry run updater
|
||||||
# dry run updater
|
self.dry_run =args.d or None
|
||||||
dry_run = args.d or None
|
# enable reconnect endpoint for docker database reconnect
|
||||||
# enable reconnect endpoint for docker database reconnect
|
self.reconnect_enable = args.r or os.environ.get("CALIBRE_RECONNECT", None)
|
||||||
reconnect_enable = args.r or os.environ.get("CALIBRE_RECONNECT", None)
|
# load covers from localhost
|
||||||
# load covers from localhost
|
self.allow_localhost = args.l or os.environ.get("CALIBRE_LOCALHOST", None)
|
||||||
allow_localhost = args.l or os.environ.get("CALIBRE_LOCALHOST", None)
|
# handle and check ip address argument
|
||||||
# handle and check ip address argument
|
self.ip_address = args.i or None
|
||||||
ip_address = args.i or None
|
if self.ip_address:
|
||||||
|
|
||||||
|
|
||||||
if ip_address:
|
|
||||||
try:
|
try:
|
||||||
# try to parse the given ip address with socket
|
# try to parse the given ip address with socket
|
||||||
if hasattr(socket, 'inet_pton'):
|
if hasattr(socket, 'inet_pton'):
|
||||||
if ':' in ip_address:
|
if ':' in self.ip_address:
|
||||||
socket.inet_pton(socket.AF_INET6, ip_address)
|
socket.inet_pton(socket.AF_INET6, self.ip_address)
|
||||||
else:
|
else:
|
||||||
socket.inet_pton(socket.AF_INET, ip_address)
|
socket.inet_pton(socket.AF_INET, self.ip_address)
|
||||||
else:
|
else:
|
||||||
# on windows python < 3.4, inet_pton is not available
|
# on windows python < 3.4, inet_pton is not available
|
||||||
# inet_atom only handles IPv4 addresses
|
# inet_atom only handles IPv4 addresses
|
||||||
socket.inet_aton(ip_address)
|
socket.inet_aton(self.ip_address)
|
||||||
except socket.error as err:
|
except socket.error as err:
|
||||||
print(ip_address, ':', err)
|
print(self.ip_address, ':', err)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# handle and check user password argument
|
# handle and check user password argument
|
||||||
user_credentials = args.s or None
|
self.user_credentials = args.s or None
|
||||||
if user_credentials and ":" not in user_credentials:
|
if self.user_credentials and ":" not in self.user_credentials:
|
||||||
print("No valid 'username:password' format")
|
print("No valid 'username:password' format")
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
if args.f:
|
if args.f:
|
||||||
print("Warning: -f flag is depreciated and will be removed in next version")
|
print("Warning: -f flag is depreciated and will be removed in next version")
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from . import constants, cli, logger
|
from . import constants, logger
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@ -154,12 +154,16 @@ class _Settings(_Base):
|
|||||||
# Class holds all application specific settings in calibre-web
|
# Class holds all application specific settings in calibre-web
|
||||||
class _ConfigSQL(object):
|
class _ConfigSQL(object):
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
def __init__(self, session):
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def init_config(self, session, cli):
|
||||||
self._session = session
|
self._session = session
|
||||||
self._settings = None
|
self._settings = None
|
||||||
self.db_configured = None
|
self.db_configured = None
|
||||||
self.config_calibre_dir = None
|
self.config_calibre_dir = None
|
||||||
self.load()
|
self.load()
|
||||||
|
self.cli = cli
|
||||||
|
|
||||||
change = False
|
change = False
|
||||||
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
||||||
@ -184,22 +188,21 @@ class _ConfigSQL(object):
|
|||||||
return self._settings
|
return self._settings
|
||||||
|
|
||||||
def get_config_certfile(self):
|
def get_config_certfile(self):
|
||||||
if cli.certfilepath:
|
if self.cli.certfilepath:
|
||||||
return cli.certfilepath
|
return self.cli.certfilepath
|
||||||
if cli.certfilepath == "":
|
if self.cli.certfilepath == "":
|
||||||
return None
|
return None
|
||||||
return self.config_certfile
|
return self.config_certfile
|
||||||
|
|
||||||
def get_config_keyfile(self):
|
def get_config_keyfile(self):
|
||||||
if cli.keyfilepath:
|
if self.cli.keyfilepath:
|
||||||
return cli.keyfilepath
|
return self.cli.keyfilepath
|
||||||
if cli.certfilepath == "":
|
if self.cli.certfilepath == "":
|
||||||
return None
|
return None
|
||||||
return self.config_keyfile
|
return self.config_keyfile
|
||||||
|
|
||||||
@staticmethod
|
def get_config_ipaddress(self):
|
||||||
def get_config_ipaddress():
|
return self.cli.ip_address or ""
|
||||||
return cli.ip_address or ""
|
|
||||||
|
|
||||||
def _has_role(self, role_flag):
|
def _has_role(self, role_flag):
|
||||||
return constants.has_flag(self.config_default_role, role_flag)
|
return constants.has_flag(self.config_default_role, role_flag)
|
||||||
@ -449,14 +452,15 @@ def _migrate_database(session):
|
|||||||
_migrate_table(session, _Flask_Settings)
|
_migrate_table(session, _Flask_Settings)
|
||||||
|
|
||||||
|
|
||||||
def load_configuration(session):
|
def load_configuration(conf, session, cli):
|
||||||
_migrate_database(session)
|
_migrate_database(session)
|
||||||
|
|
||||||
if not session.query(_Settings).count():
|
if not session.query(_Settings).count():
|
||||||
session.add(_Settings())
|
session.add(_Settings())
|
||||||
session.commit()
|
session.commit()
|
||||||
conf = _ConfigSQL(session)
|
# conf = _ConfigSQL()
|
||||||
return conf
|
conf.init_config(session, cli)
|
||||||
|
# return conf
|
||||||
|
|
||||||
def get_flask_session_key(_session):
|
def get_flask_session_key(_session):
|
||||||
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
||||||
|
@ -89,7 +89,7 @@ books_publishers_link = Table('books_publishers_link', Base.metadata,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LibraryId(Base):
|
class Library_Id(Base):
|
||||||
__tablename__ = 'library_id'
|
__tablename__ = 'library_id'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
uuid = Column(String, nullable=False)
|
uuid = Column(String, nullable=False)
|
||||||
@ -440,10 +440,12 @@ class CalibreDB:
|
|||||||
# instances alive once they reach the end of their respective scopes
|
# instances alive once they reach the end of their respective scopes
|
||||||
instances = WeakSet()
|
instances = WeakSet()
|
||||||
|
|
||||||
def __init__(self, expire_on_commit=True):
|
def __init__(self):
|
||||||
""" Initialize a new CalibreDB session
|
""" Initialize a new CalibreDB session
|
||||||
"""
|
"""
|
||||||
self.session = None
|
self.session = None
|
||||||
|
|
||||||
|
def init_db(self, expire_on_commit=True):
|
||||||
if self._init:
|
if self._init:
|
||||||
self.init_session(expire_on_commit)
|
self.init_session(expire_on_commit)
|
||||||
|
|
||||||
@ -543,7 +545,7 @@ class CalibreDB:
|
|||||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||||
local_session = scoped_session(sessionmaker())
|
local_session = scoped_session(sessionmaker())
|
||||||
local_session.configure(bind=connection)
|
local_session.configure(bind=connection)
|
||||||
database_uuid = local_session().query(LibraryId).one_or_none()
|
database_uuid = local_session().query(Library_Id).one_or_none()
|
||||||
# local_session.dispose()
|
# local_session.dispose()
|
||||||
|
|
||||||
check_engine.connect()
|
check_engine.connect()
|
||||||
|
@ -49,7 +49,7 @@ from .usermanagement import login_required_if_no_ano
|
|||||||
from .kobo_sync_status import change_archived_books
|
from .kobo_sync_status import change_archived_books
|
||||||
|
|
||||||
|
|
||||||
EditBook = Blueprint('edit-book', __name__)
|
editbook = Blueprint('edit-book', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
@ -228,14 +228,14 @@ def modify_identifiers(input_identifiers, db_identifiers, db_session):
|
|||||||
return changed, error
|
return changed, error
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/ajax/delete/<int:book_id>", methods=["POST"])
|
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_book_from_details(book_id):
|
def delete_book_from_details(book_id):
|
||||||
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
|
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
|
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
|
||||||
@EditBook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_book_ajax(book_id, book_format):
|
def delete_book_ajax(book_id, book_format):
|
||||||
return delete_book_from_table(book_id, book_format, False)
|
return delete_book_from_table(book_id, book_format, False)
|
||||||
@ -743,14 +743,14 @@ def handle_author_on_edit(book, author_name, update_stored=True):
|
|||||||
return input_authors, change, renamed
|
return input_authors, change, renamed
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/admin/book/<int:book_id>", methods=['GET'])
|
@editbook.route("/admin/book/<int:book_id>", methods=['GET'])
|
||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
@edit_required
|
@edit_required
|
||||||
def show_edit_book(book_id):
|
def show_edit_book(book_id):
|
||||||
return render_edit_book(book_id)
|
return render_edit_book(book_id)
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/admin/book/<int:book_id>", methods=['POST'])
|
@editbook.route("/admin/book/<int:book_id>", methods=['POST'])
|
||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
@edit_required
|
@edit_required
|
||||||
def edit_book(book_id):
|
def edit_book(book_id):
|
||||||
@ -1084,7 +1084,7 @@ def move_coverfile(meta, db_book):
|
|||||||
category="error")
|
category="error")
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/upload", methods=["POST"])
|
@editbook.route("/upload", methods=["POST"])
|
||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
@upload_required
|
@upload_required
|
||||||
def upload():
|
def upload():
|
||||||
@ -1153,7 +1153,7 @@ def upload():
|
|||||||
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/admin/book/convert/<int:book_id>", methods=['POST'])
|
@editbook.route("/admin/book/convert/<int:book_id>", methods=['POST'])
|
||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
@edit_required
|
@edit_required
|
||||||
def convert_bookformat(book_id):
|
def convert_bookformat(book_id):
|
||||||
@ -1178,7 +1178,7 @@ def convert_bookformat(book_id):
|
|||||||
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/ajax/getcustomenum/<int:c_id>")
|
@editbook.route("/ajax/getcustomenum/<int:c_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def table_get_custom_enum(c_id):
|
def table_get_custom_enum(c_id):
|
||||||
ret = list()
|
ret = list()
|
||||||
@ -1191,7 +1191,7 @@ def table_get_custom_enum(c_id):
|
|||||||
return json.dumps(ret)
|
return json.dumps(ret)
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/ajax/editbooks/<param>", methods=['POST'])
|
@editbook.route("/ajax/editbooks/<param>", methods=['POST'])
|
||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
@edit_required
|
@edit_required
|
||||||
def edit_list_book(param):
|
def edit_list_book(param):
|
||||||
@ -1303,7 +1303,7 @@ def edit_list_book(param):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/ajax/sort_value/<field>/<int:bookid>")
|
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
|
||||||
@login_required
|
@login_required
|
||||||
def get_sorted_entry(field, bookid):
|
def get_sorted_entry(field, bookid):
|
||||||
if field in ['title', 'authors', 'sort', 'author_sort']:
|
if field in ['title', 'authors', 'sort', 'author_sort']:
|
||||||
@ -1320,7 +1320,7 @@ def get_sorted_entry(field, bookid):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/ajax/simulatemerge", methods=['POST'])
|
@editbook.route("/ajax/simulatemerge", methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def simulate_merge_list_book():
|
def simulate_merge_list_book():
|
||||||
@ -1336,7 +1336,7 @@ def simulate_merge_list_book():
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/ajax/mergebooks", methods=['POST'])
|
@editbook.route("/ajax/mergebooks", methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def merge_list_book():
|
def merge_list_book():
|
||||||
@ -1374,7 +1374,7 @@ def merge_list_book():
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@EditBook.route("/ajax/xchange", methods=['POST'])
|
@editbook.route("/ajax/xchange", methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@edit_required
|
@edit_required
|
||||||
def table_xchange_author_title():
|
def table_xchange_author_title():
|
||||||
|
@ -63,7 +63,7 @@ except ImportError as err:
|
|||||||
importError = err
|
importError = err
|
||||||
gdrive_support = False
|
gdrive_support = False
|
||||||
|
|
||||||
from . import logger, cli, config
|
from . import logger, cli_param, config
|
||||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
|
|
||||||
|
|
||||||
@ -142,7 +142,7 @@ def is_gdrive_ready():
|
|||||||
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
|
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
|
||||||
|
|
||||||
|
|
||||||
engine = create_engine('sqlite:///{0}'.format(cli.gd_path), echo=False)
|
engine = create_engine('sqlite:///{0}'.format(cli_param.gd_path), echo=False)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
# Open session for database connection
|
# Open session for database connection
|
||||||
@ -190,11 +190,11 @@ def migrate():
|
|||||||
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
|
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
|
||||||
break
|
break
|
||||||
|
|
||||||
if not os.path.exists(cli.gd_path):
|
if not os.path.exists(cli_param.gd_path):
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error("Error connect to database: {} - {}".format(cli.gd_path, ex))
|
log.error("Error connect to database: {} - {}".format(cli_param.gd_path, ex))
|
||||||
raise
|
raise
|
||||||
migrate()
|
migrate()
|
||||||
|
|
||||||
@ -544,6 +544,7 @@ def deleteDatabaseOnChange():
|
|||||||
except (OperationalError, InvalidRequestError) as ex:
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
log.error_or_exception('Database error: {}'.format(ex))
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
def updateGdriveCalibreFromLocal():
|
def updateGdriveCalibreFromLocal():
|
||||||
|
71
cps/main.py
Normal file
71
cps/main.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2012-2022 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# 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 sys
|
||||||
|
|
||||||
|
from . import create_app
|
||||||
|
from .jinjia import jinjia
|
||||||
|
from .shelf import shelf
|
||||||
|
from .remotelogin import remotelogin
|
||||||
|
from .search_metadata import meta
|
||||||
|
from .error_handler import init_errorhandler
|
||||||
|
|
||||||
|
try:
|
||||||
|
from kobo import kobo, get_kobo_activated
|
||||||
|
from kobo_auth import kobo_auth
|
||||||
|
kobo_available = get_kobo_activated()
|
||||||
|
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||||
|
kobo_available = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from oauth_bb import oauth
|
||||||
|
oauth_available = True
|
||||||
|
except ImportError:
|
||||||
|
oauth_available = False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
from .web import web
|
||||||
|
from .opds import opds
|
||||||
|
from .admin import admi
|
||||||
|
from .gdrive import gdrive
|
||||||
|
from .editbooks import editbook
|
||||||
|
from .about import about
|
||||||
|
|
||||||
|
from . import web_server
|
||||||
|
init_errorhandler()
|
||||||
|
|
||||||
|
app.register_blueprint(web)
|
||||||
|
app.register_blueprint(opds)
|
||||||
|
app.register_blueprint(jinjia)
|
||||||
|
app.register_blueprint(about)
|
||||||
|
app.register_blueprint(shelf)
|
||||||
|
app.register_blueprint(admi) #
|
||||||
|
app.register_blueprint(remotelogin)
|
||||||
|
app.register_blueprint(meta)
|
||||||
|
app.register_blueprint(gdrive)
|
||||||
|
app.register_blueprint(editbook)
|
||||||
|
if kobo_available:
|
||||||
|
app.register_blueprint(kobo)
|
||||||
|
app.register_blueprint(kobo_auth)
|
||||||
|
if oauth_available:
|
||||||
|
app.register_blueprint(oauth)
|
||||||
|
success = web_server.start()
|
||||||
|
sys.exit(0 if success else 1)
|
@ -24,11 +24,10 @@ import mimetypes
|
|||||||
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import parseaddr
|
from email.utils import formatdate, parseaddr
|
||||||
|
from email.generator import Generator
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
from email.generator import Generator
|
|
||||||
|
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
from cps.services import gmail
|
from cps.services import gmail
|
||||||
|
@ -210,7 +210,7 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2>{{_('Update')}}</h2>
|
<h2>{{_('Version Information')}}</h2>
|
||||||
<table class="table table-striped" id="update_table">
|
<table class="table table-striped" id="update_table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -53,7 +53,7 @@ except ImportError:
|
|||||||
from sqlalchemy.orm import backref, relationship, sessionmaker, Session, scoped_session
|
from sqlalchemy.orm import backref, relationship, sessionmaker, Session, scoped_session
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from . import constants, logger, cli
|
from . import constants, logger
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@ -816,7 +816,7 @@ def init_db_thread():
|
|||||||
return Session()
|
return Session()
|
||||||
|
|
||||||
|
|
||||||
def init_db(app_db_path):
|
def init_db(app_db_path, user_credentials=None):
|
||||||
# Open session for database connection
|
# Open session for database connection
|
||||||
global session
|
global session
|
||||||
global app_DB_path
|
global app_DB_path
|
||||||
@ -837,8 +837,8 @@ def init_db(app_db_path):
|
|||||||
create_admin_user(session)
|
create_admin_user(session)
|
||||||
create_anonymous_user(session)
|
create_anonymous_user(session)
|
||||||
|
|
||||||
if cli.user_credentials:
|
if user_credentials:
|
||||||
username, password = cli.user_credentials.split(':', 1)
|
username, password = user_credentials.split(':', 1)
|
||||||
user = session.query(User).filter(func.lower(User.name) == username.lower()).first()
|
user = session.query(User).filter(func.lower(User.name) == username.lower()).first()
|
||||||
if user:
|
if user:
|
||||||
if not password:
|
if not password:
|
||||||
|
@ -31,7 +31,7 @@ import requests
|
|||||||
from babel.dates import format_datetime
|
from babel.dates import format_datetime
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from . import constants, logger, config, web_server
|
from . import constants, logger # config, web_server
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@ -58,13 +58,17 @@ class Updater(threading.Thread):
|
|||||||
self.status = -1
|
self.status = -1
|
||||||
self.updateIndex = None
|
self.updateIndex = None
|
||||||
|
|
||||||
|
def init_updater(self, config, web_server):
|
||||||
|
self.config = config
|
||||||
|
self.web_server = web_server
|
||||||
|
|
||||||
def get_current_version_info(self):
|
def get_current_version_info(self):
|
||||||
if config.config_updatechannel == constants.UPDATE_STABLE:
|
if self.config.config_updatechannel == constants.UPDATE_STABLE:
|
||||||
return self._stable_version_info()
|
return self._stable_version_info()
|
||||||
return self._nightly_version_info()
|
return self._nightly_version_info()
|
||||||
|
|
||||||
def get_available_updates(self, request_method, locale):
|
def get_available_updates(self, request_method, locale):
|
||||||
if config.config_updatechannel == constants.UPDATE_STABLE:
|
if self.config.config_updatechannel == constants.UPDATE_STABLE:
|
||||||
return self._stable_available_updates(request_method)
|
return self._stable_available_updates(request_method)
|
||||||
return self._nightly_available_updates(request_method, locale)
|
return self._nightly_available_updates(request_method, locale)
|
||||||
|
|
||||||
@ -95,7 +99,7 @@ class Updater(threading.Thread):
|
|||||||
self.status = 6
|
self.status = 6
|
||||||
log.debug(u'Preparing restart of server')
|
log.debug(u'Preparing restart of server')
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
web_server.stop(True)
|
self.web_server.stop(True)
|
||||||
self.status = 7
|
self.status = 7
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
return True
|
return True
|
||||||
|
@ -54,7 +54,7 @@ install_requires =
|
|||||||
tornado>=4.1,<6.2
|
tornado>=4.1,<6.2
|
||||||
Wand>=0.4.4,<0.7.0
|
Wand>=0.4.4,<0.7.0
|
||||||
unidecode>=0.04.19,<1.4.0
|
unidecode>=0.04.19,<1.4.0
|
||||||
lxml>=3.8.0,<4.8.0
|
lxml>=3.8.0,<4.9.0
|
||||||
flask-wtf>=0.14.2,<1.1.0
|
flask-wtf>=0.14.2,<1.1.0
|
||||||
chardet>=3.0.0,<4.1.0
|
chardet>=3.0.0,<4.1.0
|
||||||
advocate>=1.0.0,<1.1.0
|
advocate>=1.0.0,<1.1.0
|
||||||
@ -62,7 +62,7 @@ install_requires =
|
|||||||
|
|
||||||
[options.extras_require]
|
[options.extras_require]
|
||||||
gdrive =
|
gdrive =
|
||||||
google-api-python-client>=1.7.11,<2.44.0
|
google-api-python-client>=1.7.11,<2.46.0
|
||||||
gevent>20.6.0,<22.0.0
|
gevent>20.6.0,<22.0.0
|
||||||
greenlet>=0.4.17,<1.2.0
|
greenlet>=0.4.17,<1.2.0
|
||||||
httplib2>=0.9.2,<0.21.0
|
httplib2>=0.9.2,<0.21.0
|
||||||
@ -75,7 +75,7 @@ gdrive =
|
|||||||
rsa>=3.4.2,<4.9.0
|
rsa>=3.4.2,<4.9.0
|
||||||
gmail =
|
gmail =
|
||||||
google-auth-oauthlib>=0.4.3,<0.6.0
|
google-auth-oauthlib>=0.4.3,<0.6.0
|
||||||
google-api-python-client>=1.7.11,<2.44.0
|
google-api-python-client>=1.7.11,<2.46.0
|
||||||
goodreads =
|
goodreads =
|
||||||
goodreads>=0.3.2,<0.4.0
|
goodreads>=0.3.2,<0.4.0
|
||||||
python-Levenshtein>=0.12.0,<0.13.0
|
python-Levenshtein>=0.12.0,<0.13.0
|
||||||
|
Loading…
Reference in New Issue
Block a user