mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-11-26 08:15:00 -05:00
586 lines
22 KiB
Python
586 lines
22 KiB
Python
#!/usr/bin/env python
|
|
# License: GPLv3 Copyright: 2015, Kovid Goyal <kovid at kovidgoyal.net>
|
|
|
|
|
|
import apsw
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from qt.core import QCoreApplication, QIcon, QObject, QTimer
|
|
|
|
from calibre import force_unicode, prints
|
|
from calibre.constants import (
|
|
DEBUG, MAIN_APP_UID, __appname__, filesystem_encoding, get_portable_base, islinux,
|
|
ismacos, iswindows,
|
|
)
|
|
from calibre.gui2 import (
|
|
Application, choose_dir, error_dialog, gprefs, initialize_file_icon_provider,
|
|
question_dialog, setup_gui_option_parser, timed_print,
|
|
)
|
|
from calibre.gui2.listener import send_message_in_process
|
|
from calibre.gui2.main_window import option_parser as _option_parser
|
|
from calibre.gui2.splash_screen import SplashScreen
|
|
from calibre.utils.config import dynamic, prefs
|
|
from calibre.utils.lock import SingleInstance
|
|
from calibre.utils.monotonic import monotonic
|
|
from calibre.utils.resources import get_image_path as I
|
|
from polyglot.builtins import as_bytes, environ_item
|
|
|
|
after_quit_actions = {'debug_on_restart': False, 'restart_after_quit': False, 'no_plugins_on_restart': False}
|
|
if iswindows:
|
|
from calibre_extensions import winutil
|
|
|
|
|
|
class AbortInit(Exception):
|
|
pass
|
|
|
|
|
|
def option_parser():
|
|
parser = _option_parser(_('''\
|
|
%prog [options] [path_to_ebook or calibre url ...]
|
|
|
|
Launch the main calibre Graphical User Interface and optionally add the e-book at
|
|
path_to_ebook to the database. You can also specify calibre URLs to perform various
|
|
different actions, than just adding books. For example:
|
|
|
|
calibre://view-book/test_library/1842/epub
|
|
|
|
Will open the book with id 1842 in the EPUB format from the library
|
|
"test_library" in the calibre E-book viewer. Library names are the folder names of the
|
|
libraries with spaces replaced by underscores. A full description of the
|
|
various URL based actions is in the User Manual.
|
|
'''))
|
|
parser.add_option('--with-library', default=None, action='store',
|
|
help=_('Use the library located at the specified path.'))
|
|
parser.add_option('--start-in-tray', default=False, action='store_true',
|
|
help=_('Start minimized to system tray.'))
|
|
parser.add_option('-v', '--verbose', default=0, action='count',
|
|
help=_('Ignored, do not use. Present only for legacy reasons'))
|
|
parser.add_option('--no-update-check', default=False, action='store_true',
|
|
help=_('Do not check for updates'))
|
|
parser.add_option('--ignore-plugins', default=False, action='store_true',
|
|
help=_('Ignore custom plugins, useful if you installed a plugin'
|
|
' that is preventing calibre from starting'))
|
|
parser.add_option('-s', '--shutdown-running-calibre', default=False,
|
|
action='store_true',
|
|
help=_('Cause a running calibre instance, if any, to be'
|
|
' shutdown. Note that if there are running jobs, they '
|
|
'will be silently aborted, so use with care.'))
|
|
setup_gui_option_parser(parser)
|
|
return parser
|
|
|
|
|
|
def find_portable_library():
|
|
base = get_portable_base()
|
|
if base is None:
|
|
return
|
|
import glob
|
|
candidates = [os.path.basename(os.path.dirname(x)) for x in glob.glob(
|
|
os.path.join(base, '*%smetadata.db'%os.sep))]
|
|
if not candidates:
|
|
candidates = ['Calibre Library']
|
|
lp = prefs['library_path']
|
|
if not lp:
|
|
lib = os.path.join(base, candidates[0])
|
|
else:
|
|
lib = None
|
|
q = os.path.basename(lp)
|
|
for c in candidates:
|
|
c = c
|
|
if c.lower() == q.lower():
|
|
lib = os.path.join(base, c)
|
|
break
|
|
if lib is None:
|
|
lib = os.path.join(base, candidates[0])
|
|
|
|
if len(lib) > 74:
|
|
error_dialog(None, _('Path too long'),
|
|
_("Path to Calibre Portable (%s) "
|
|
'too long. It must be less than 59 characters.')%base, show=True)
|
|
raise AbortInit()
|
|
|
|
prefs.set('library_path', lib)
|
|
if not os.path.exists(lib):
|
|
os.mkdir(lib)
|
|
|
|
|
|
def init_qt(args):
|
|
parser = option_parser()
|
|
opts, args = parser.parse_args(args)
|
|
if os.environ.pop('CALIBRE_IGNORE_PLUGINS_ON_RESTART', '') == '1':
|
|
opts.ignore_plugins = True
|
|
find_portable_library()
|
|
if opts.with_library is not None:
|
|
libpath = os.path.expanduser(opts.with_library)
|
|
if not os.path.exists(libpath):
|
|
os.makedirs(libpath)
|
|
if os.path.isdir(libpath):
|
|
prefs.set('library_path', os.path.abspath(libpath))
|
|
prints('Using library at', prefs['library_path'])
|
|
override = 'calibre-gui' if islinux else None
|
|
app = Application(args, override_program_name=override, windows_app_uid=MAIN_APP_UID)
|
|
|
|
app.file_event_hook = EventAccumulator()
|
|
try:
|
|
is_x11 = app.platformName() == 'xcb'
|
|
except Exception:
|
|
import traceback
|
|
traceback.print_exc()
|
|
is_x11 = False
|
|
# Ancient broken VNC servers cannot handle icons of size greater than 256
|
|
# https://www.mobileread.com/forums/showthread.php?t=278447
|
|
ic = 'lt.png' if is_x11 else 'library.png'
|
|
app.setWindowIcon(QIcon(I(ic, allow_user_override=False)))
|
|
return app, opts, args
|
|
|
|
|
|
def get_default_library_path():
|
|
fname = _('Calibre Library')
|
|
if iswindows:
|
|
fname = 'Calibre Library'
|
|
if isinstance(fname, str):
|
|
try:
|
|
fname.encode(filesystem_encoding)
|
|
except Exception:
|
|
fname = 'Calibre Library'
|
|
x = os.path.expanduser(os.path.join('~', fname))
|
|
if not os.path.exists(x):
|
|
try:
|
|
os.makedirs(x)
|
|
except Exception:
|
|
x = os.path.expanduser('~')
|
|
return x
|
|
|
|
|
|
def try_other_known_library_paths():
|
|
stats = gprefs.get('library_usage_stats', {})
|
|
if stats:
|
|
for candidate in sorted(stats.keys(), key=stats.__getitem__, reverse=True):
|
|
candidate = os.path.abspath(candidate)
|
|
if os.path.exists(candidate):
|
|
return candidate
|
|
|
|
|
|
def get_library_path(gui_runner):
|
|
library_path = prefs['library_path']
|
|
if library_path is None: # Need to migrate to new database layout
|
|
base = os.path.expanduser('~')
|
|
if not base or not os.path.exists(base):
|
|
from qt.core import QDir
|
|
base = str(QDir.homePath()).replace('/', os.sep)
|
|
candidate = gui_runner.choose_dir(base)
|
|
if not candidate:
|
|
candidate = os.path.join(base, 'Calibre Library')
|
|
library_path = os.path.abspath(candidate)
|
|
elif not os.path.exists(library_path):
|
|
q = try_other_known_library_paths()
|
|
if q:
|
|
library_path = q
|
|
if not os.path.exists(library_path):
|
|
try:
|
|
os.makedirs(library_path)
|
|
except:
|
|
gui_runner.show_error(_('Failed to create library'),
|
|
_('Failed to create calibre library at: %r.\n'
|
|
'You will be asked to choose a new library location.')%library_path,
|
|
det_msg=traceback.format_exc())
|
|
library_path = gui_runner.choose_dir(get_default_library_path())
|
|
return library_path
|
|
|
|
|
|
def repair_library(library_path):
|
|
from calibre.gui2.dialogs.restore_library import repair_library_at
|
|
return repair_library_at(library_path)
|
|
|
|
|
|
def windows_repair(library_path=None):
|
|
import subprocess
|
|
|
|
from calibre.utils.serialize import json_dumps, json_loads
|
|
from polyglot.binary import as_hex_unicode, from_hex_bytes
|
|
if library_path:
|
|
library_path = as_hex_unicode(json_dumps(library_path))
|
|
winutil.prepare_for_restart()
|
|
os.environ['CALIBRE_REPAIR_CORRUPTED_DB'] = environ_item(library_path)
|
|
subprocess.Popen([sys.executable])
|
|
else:
|
|
try:
|
|
app = Application([])
|
|
from calibre.gui2.dialogs.restore_library import repair_library_at
|
|
library_path = json_loads(from_hex_bytes(os.environ.pop('CALIBRE_REPAIR_CORRUPTED_DB')))
|
|
done = repair_library_at(library_path, wait_time=4)
|
|
except Exception:
|
|
done = False
|
|
error_dialog(None, _('Failed to repair library'), _(
|
|
'Could not repair library. Click "Show details" for more information.'), det_msg=traceback.format_exc(), show=True)
|
|
if done:
|
|
subprocess.Popen([sys.executable])
|
|
app.quit()
|
|
|
|
|
|
class EventAccumulator:
|
|
|
|
def __init__(self):
|
|
self.events = []
|
|
|
|
def __call__(self, ev):
|
|
self.events.append(ev)
|
|
|
|
|
|
class GuiRunner(QObject):
|
|
'''Make sure an event loop is running before starting the main work of
|
|
initialization'''
|
|
|
|
def __init__(self, opts, args, actions, app, gui_debug=None):
|
|
self.startup_time = monotonic()
|
|
timed_print('Starting up...')
|
|
self.opts, self.args, self.app = opts, args, app
|
|
self.gui_debug = gui_debug
|
|
self.actions = actions
|
|
self.main = None
|
|
QObject.__init__(self)
|
|
self.splash_screen = None
|
|
self.timer = QTimer.singleShot(1, self.initialize)
|
|
|
|
def start_gui(self, db):
|
|
from calibre.gui2.ui import Main
|
|
timed_print('Constructing main UI...')
|
|
if self.splash_screen is not None:
|
|
self.splash_screen.show_message(_('Initializing user interface...'))
|
|
main = self.main = Main(self.opts, gui_debug=self.gui_debug)
|
|
try:
|
|
with gprefs: # Only write gui.json after initialization is complete
|
|
main.initialize(self.library_path, db, self.actions)
|
|
finally:
|
|
timed_print('main UI initialized...')
|
|
if self.splash_screen is not None:
|
|
timed_print('Hiding splash screen')
|
|
self.splash_screen.finish(main)
|
|
timed_print('splash screen hidden')
|
|
self.splash_screen = None
|
|
timed_print('Started up in %.2f seconds'%(monotonic() - self.startup_time), 'with', len(db.data), 'books')
|
|
main.set_exception_handler()
|
|
if len(self.args) > 1:
|
|
main.handle_cli_args(self.args[1:])
|
|
for event in self.app.file_event_hook.events:
|
|
main.handle_cli_args(event)
|
|
self.app.file_event_hook = main.handle_cli_args
|
|
|
|
def choose_dir(self, initial_dir):
|
|
self.hide_splash_screen()
|
|
return choose_dir(self.splash_screen, 'choose calibre library',
|
|
_('Choose a location for your new calibre e-book library'),
|
|
default_dir=initial_dir)
|
|
|
|
def show_error(self, title, msg, det_msg=''):
|
|
print(det_msg, file=sys.stderr)
|
|
self.hide_splash_screen()
|
|
with self.app:
|
|
error_dialog(self.splash_screen, title, msg, det_msg=det_msg, show=True)
|
|
|
|
def initialization_failed(self):
|
|
print('Catastrophic failure initializing GUI, bailing out...')
|
|
QCoreApplication.exit(1)
|
|
raise SystemExit(1)
|
|
|
|
def initialize_db_stage2(self, db, tb):
|
|
from calibre.db.legacy import LibraryDatabase
|
|
|
|
if db is None and tb is not None:
|
|
# DB Repair failed
|
|
self.show_error(_('Repairing failed'), _(
|
|
'The database repair failed. Starting with a new empty library.'),
|
|
det_msg=tb)
|
|
if db is None:
|
|
candidate = self.choose_dir(get_default_library_path())
|
|
if not candidate:
|
|
self.initialization_failed()
|
|
|
|
try:
|
|
self.library_path = candidate
|
|
db = LibraryDatabase(candidate)
|
|
except:
|
|
self.show_error(_('Bad database location'), _(
|
|
'Bad database location %r. calibre will now quit.')%self.library_path,
|
|
det_msg=traceback.format_exc())
|
|
self.initialization_failed()
|
|
|
|
timed_print('db initialized')
|
|
try:
|
|
self.start_gui(db)
|
|
except Exception:
|
|
try:
|
|
details = traceback.format_exc()
|
|
except Exception:
|
|
details = ''
|
|
self.show_error(_('Startup error'), _(
|
|
'There was an error during {0} startup. Parts of {0} may not function.'
|
|
' Click "Show details" to learn more.').format(__appname__), det_msg=details)
|
|
|
|
def initialize_db(self):
|
|
from calibre.db.legacy import LibraryDatabase
|
|
db = None
|
|
timed_print('Initializing db...')
|
|
try:
|
|
db = LibraryDatabase(self.library_path)
|
|
except apsw.Error:
|
|
with self.app:
|
|
self.hide_splash_screen()
|
|
repair = question_dialog(self.splash_screen, _('Corrupted database'),
|
|
_('The library database at %s appears to be corrupted. Do '
|
|
'you want calibre to try and rebuild it automatically? '
|
|
'The rebuild may not be completely successful. '
|
|
'If you say No, a new empty calibre library will be created.')
|
|
% force_unicode(self.library_path, filesystem_encoding),
|
|
det_msg=traceback.format_exc()
|
|
)
|
|
if repair:
|
|
if iswindows:
|
|
# On some windows systems the existing db file gets locked
|
|
# by something when running restore from the main process.
|
|
# So run the restore in a separate process.
|
|
windows_repair(self.library_path)
|
|
self.app.quit()
|
|
return
|
|
if repair_library(self.library_path):
|
|
db = LibraryDatabase(self.library_path)
|
|
except:
|
|
self.show_error(_('Bad database location'),
|
|
_('Bad database location %r. Will start with '
|
|
' a new, empty calibre library')%self.library_path,
|
|
det_msg=traceback.format_exc())
|
|
|
|
self.initialize_db_stage2(db, None)
|
|
|
|
def show_splash_screen(self):
|
|
timed_print('Showing splash screen...')
|
|
self.splash_screen = SplashScreen()
|
|
self.splash_screen.show()
|
|
self.splash_screen.show_message(_('Starting %s: Loading books...') % __appname__)
|
|
timed_print('splash screen shown')
|
|
|
|
def hide_splash_screen(self):
|
|
if self.splash_screen is not None:
|
|
self.splash_screen.hide()
|
|
self.splash_screen = None
|
|
|
|
def initialize(self, *args):
|
|
if gprefs['show_splash_screen'] and not self.opts.start_in_tray:
|
|
self.show_splash_screen()
|
|
self.library_path = get_library_path(self)
|
|
if not self.library_path:
|
|
self.initialization_failed()
|
|
|
|
self.initialize_db()
|
|
|
|
|
|
def run_in_debug_mode():
|
|
import subprocess
|
|
import tempfile
|
|
|
|
from calibre.debug import run_calibre_debug
|
|
fd, logpath = tempfile.mkstemp('.txt')
|
|
os.close(fd)
|
|
run_calibre_debug(
|
|
'--gui-debug', logpath, stdout=open(logpath, 'wb'),
|
|
stderr=subprocess.STDOUT, stdin=open(os.devnull, 'rb'))
|
|
|
|
|
|
def run_gui(opts, args, app, gui_debug=None):
|
|
with SingleInstance('db') as si:
|
|
if not si:
|
|
ext = '.exe' if iswindows else ''
|
|
error_dialog(None, _('Cannot start calibre'), _(
|
|
'Another calibre program that can modify calibre libraries, such as,'
|
|
' {0} or {1} is already running. You must first shut it down, before'
|
|
' starting the main calibre program. If you are sure no such'
|
|
' program is running, try restarting your computer.').format(
|
|
'calibre-server' + ext, 'calibredb' + ext), show=True)
|
|
return 1
|
|
run_gui_(opts, args, app, gui_debug)
|
|
|
|
|
|
def run_gui_(opts, args, app, gui_debug=None):
|
|
initialize_file_icon_provider()
|
|
app.load_builtin_fonts(scan_for_fonts=True)
|
|
if not dynamic.get('welcome_wizard_was_run', False):
|
|
from calibre.gui2.wizard import wizard
|
|
wizard().exec()
|
|
dynamic.set('welcome_wizard_was_run', True)
|
|
from calibre.gui2.ui import Main
|
|
if ismacos:
|
|
actions = tuple(Main.create_application_menubar())
|
|
else:
|
|
actions = tuple(Main.get_menubar_actions())
|
|
runner = GuiRunner(opts, args, actions, app, gui_debug=gui_debug)
|
|
ret = app.exec()
|
|
if getattr(runner.main, 'run_wizard_b4_shutdown', False):
|
|
from calibre.gui2.wizard import wizard
|
|
wizard().exec()
|
|
if getattr(runner.main, 'restart_after_quit', False):
|
|
after_quit_actions['restart_after_quit'] = True
|
|
after_quit_actions['debug_on_restart'] = getattr(runner.main, 'debug_on_restart', False) or gui_debug is not None
|
|
after_quit_actions['no_plugins_on_restart'] = getattr(runner.main, 'no_plugins_on_restart', False)
|
|
else:
|
|
if iswindows:
|
|
try:
|
|
runner.main.system_tray_icon.hide()
|
|
except:
|
|
pass
|
|
if getattr(runner.main, 'gui_debug', None) is not None:
|
|
debugfile = runner.main.gui_debug
|
|
from calibre.gui2 import open_local_file
|
|
if iswindows:
|
|
# detach the stdout/stderr/stdin handles
|
|
winutil.prepare_for_restart()
|
|
with open(debugfile, 'r+b') as f:
|
|
raw = f.read()
|
|
raw = re.sub(b'(?<!\r)\n', b'\r\n', raw)
|
|
f.seek(0)
|
|
f.truncate()
|
|
f.write(raw)
|
|
open_local_file(debugfile)
|
|
return ret
|
|
|
|
|
|
singleinstance_name = 'GUI'
|
|
|
|
|
|
class FailedToCommunicate(Exception):
|
|
pass
|
|
|
|
|
|
def send_message(msg, retry_communicate=False):
|
|
try:
|
|
send_message_in_process(msg)
|
|
except Exception:
|
|
time.sleep(2)
|
|
try:
|
|
send_message_in_process(msg)
|
|
except Exception as err:
|
|
# can happen because the Qt local server pipe is shutdown before
|
|
# the single instance mutex is released
|
|
if retry_communicate:
|
|
raise FailedToCommunicate('retrying')
|
|
print(_('Failed to contact running instance of calibre'), file=sys.stderr, flush=True)
|
|
print(err, file=sys.stderr, flush=True)
|
|
if Application.instance():
|
|
error_dialog(None, _('Contacting calibre failed'), _(
|
|
'Failed to contact running instance of calibre, try restarting calibre'),
|
|
det_msg=str(err) + '\n\n' + repr(msg), show=True)
|
|
return False
|
|
return True
|
|
|
|
|
|
def shutdown_other():
|
|
if send_message('shutdown:'):
|
|
print(_('Shutdown command sent, waiting for shutdown...'), flush=True)
|
|
for i in range(50):
|
|
with SingleInstance(singleinstance_name) as si:
|
|
if si:
|
|
return
|
|
time.sleep(0.1)
|
|
raise SystemExit(_('Failed to shutdown running calibre instance'))
|
|
|
|
|
|
def communicate(opts, args, retry_communicate=False):
|
|
if opts.shutdown_running_calibre:
|
|
shutdown_other()
|
|
else:
|
|
if len(args) > 1:
|
|
args[1:] = [os.path.abspath(x) if os.path.exists(x) else x for x in args[1:]]
|
|
if opts.with_library and os.path.isdir(os.path.expanduser(opts.with_library)):
|
|
library_id = os.path.basename(opts.with_library).replace(' ', '_').encode('utf-8').hex()
|
|
args.insert(1, 'calibre://switch-library/_hex_-' + library_id)
|
|
import json
|
|
if not send_message(b'launched:'+as_bytes(json.dumps(args)), retry_communicate=retry_communicate):
|
|
raise SystemExit(_('Failed to contact running instance of calibre'))
|
|
raise SystemExit(0)
|
|
|
|
|
|
def restart_after_quit():
|
|
e = sys.executable if getattr(sys, 'frozen', False) else sys.argv[0]
|
|
is_calibre_debug_exe = os.path.splitext(e)[0].endswith('-debug')
|
|
if iswindows and not is_calibre_debug_exe:
|
|
# detach the stdout/stderr/stdin handles
|
|
winutil.prepare_for_restart()
|
|
if after_quit_actions['no_plugins_on_restart']:
|
|
os.environ['CALIBRE_IGNORE_PLUGINS_ON_RESTART'] = '1'
|
|
if after_quit_actions['debug_on_restart']:
|
|
run_in_debug_mode()
|
|
return
|
|
if hasattr(sys, 'frameworks_dir'):
|
|
app = os.path.dirname(os.path.dirname(os.path.realpath(sys.frameworks_dir)))
|
|
from calibre.debug import run_calibre_debug
|
|
prints('Restarting with:', app)
|
|
run_calibre_debug('-c', 'import sys, os, time; time.sleep(3); os.execlp("open", "open", sys.argv[-1])', app)
|
|
else:
|
|
import subprocess
|
|
if hasattr(sys, 'run_local'):
|
|
cmd = [sys.run_local]
|
|
if DEBUG:
|
|
cmd += ['calibre-debug', '-g']
|
|
else:
|
|
cmd.append('calibre')
|
|
else:
|
|
cmd = [e]
|
|
if is_calibre_debug_exe:
|
|
cmd.append('-g')
|
|
prints('Restarting with:', ' '.join(cmd))
|
|
subprocess.Popen(cmd)
|
|
|
|
|
|
def main(args=sys.argv):
|
|
if iswindows and 'CALIBRE_REPAIR_CORRUPTED_DB' in os.environ:
|
|
windows_repair()
|
|
return 0
|
|
gui_debug = None
|
|
if args[0] == '__CALIBRE_GUI_DEBUG__':
|
|
gui_debug = args[1]
|
|
args = ['calibre']
|
|
|
|
try:
|
|
app, opts, args = init_qt(args)
|
|
except AbortInit:
|
|
return 1
|
|
try:
|
|
with SingleInstance(singleinstance_name) as si:
|
|
if si and opts.shutdown_running_calibre:
|
|
return 0
|
|
run_main(app, opts, args, gui_debug, si, retry_communicate=True)
|
|
except FailedToCommunicate:
|
|
with SingleInstance(singleinstance_name) as si:
|
|
if si and opts.shutdown_running_calibre:
|
|
return 0
|
|
run_main(app, opts, args, gui_debug, si, retry_communicate=False)
|
|
if after_quit_actions['restart_after_quit']:
|
|
restart_after_quit()
|
|
|
|
|
|
def run_main(app, opts, args, gui_debug, si, retry_communicate=False):
|
|
if si:
|
|
return run_gui(opts, args, app, gui_debug=gui_debug)
|
|
communicate(opts, args, retry_communicate)
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
sys.exit(main())
|
|
except Exception as err:
|
|
if not iswindows:
|
|
raise
|
|
tb = traceback.format_exc()
|
|
from qt.core import QErrorMessage
|
|
logfile = os.path.join(os.path.expanduser('~'), 'calibre.log')
|
|
if os.path.exists(logfile):
|
|
with open(logfile) as f:
|
|
log = f.read().decode('utf-8', 'ignore')
|
|
d = QErrorMessage()
|
|
d.showMessage(('<b>Error:</b>%s<br><b>Traceback:</b><br>'
|
|
'%s<b>Log:</b><br>%s')%(str(err),
|
|
str(tb).replace('\n', '<br>'),
|
|
log.replace('\n', '<br>')))
|