# -*- coding: utf-8 -*- # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # Copyright (C) 2020 pwr # # 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 . import os import re import glob from shutil import copyfile, copyfileobj from markupsafe import escape from time import time from uuid import uuid4 from sqlalchemy.exc import SQLAlchemyError from flask_babel import lazy_gettext as N_ from cps.services.worker import CalibreTask from cps import db from cps import logger, config from cps.subproc_wrapper import process_open from flask_babel import gettext as _ from cps.kobo_sync_status import remove_synced_book from cps.ub import init_db_thread from cps.file_helper import get_temp_dir from cps.tasks.mail import TaskEmail from cps import gdriveutils, helper from cps.constants import SUPPORTED_CALIBRE_BINARIES from cps.string_helper import strip_whitespaces log = logger.create() current_milli_time = lambda: int(round(time() * 1000)) class TaskConvert(CalibreTask): def __init__(self, file_path, book_id, task_message, settings, ereader_mail, user=None): super(TaskConvert, self).__init__(task_message) self.worker_thread = None self.file_path = file_path self.book_id = book_id self.title = "" self.settings = settings self.ereader_mail = ereader_mail self.user = user self.results = dict() def run(self, worker_thread): self.worker_thread = worker_thread if config.config_use_google_drive: worker_db = db.CalibreDB(expire_on_commit=False, init=True) cur_book = worker_db.get_book(self.book_id) self.title = cur_book.title data = worker_db.get_book_format(self.book_id, self.settings['old_book_format']) df = gdriveutils.getFileFromEbooksFolder(cur_book.path, data.name + "." + self.settings['old_book_format'].lower()) df_cover = gdriveutils.getFileFromEbooksFolder(cur_book.path, "cover.jpg") if df: datafile_cover = None datafile = os.path.join(config.get_book_path(), cur_book.path, data.name + "." + self.settings['old_book_format'].lower()) if df_cover: datafile_cover = os.path.join(config.get_book_path(), cur_book.path, "cover.jpg") if not os.path.exists(os.path.join(config.get_book_path(), cur_book.path)): os.makedirs(os.path.join(config.get_book_path(), cur_book.path)) df.GetContentFile(datafile) if df_cover: df_cover.GetContentFile(datafile_cover) worker_db.session.close() else: # ToDo Include cover in error handling error_message = _("%(format)s not found on Google Drive: %(fn)s", format=self.settings['old_book_format'], fn=data.name + "." + self.settings['old_book_format'].lower()) worker_db.session.close() return self._handleError(error_message) filename = self._convert_ebook_format() if config.config_use_google_drive: os.remove(self.file_path + '.' + self.settings['old_book_format'].lower()) if df_cover: os.remove(os.path.join(config.config_calibre_dir, cur_book.path, "cover.jpg")) if filename: if config.config_use_google_drive: # Upload files to gdrive gdriveutils.updateGdriveCalibreFromLocal() self._handleSuccess() if self.ereader_mail: # if we're sending to E-Reader after converting, create a one-off task and run it immediately # todo: figure out how to incorporate this into the progress try: EmailText = N_(u"%(book)s send to E-Reader", book=escape(self.title)) for email in self.ereader_mail.split(','): email = strip_whitespaces(email) worker_thread.add(self.user, TaskEmail(self.settings['subject'], self.results["path"], filename, self.settings, email, EmailText, self.settings['body'], id=self.book_id, internal=True) ) except Exception as ex: return self._handleError(str(ex)) def _convert_ebook_format(self): error_message = None local_db = db.CalibreDB(expire_on_commit=False, init=True) file_path = self.file_path book_id = self.book_id format_old_ext = '.' + self.settings['old_book_format'].lower() format_new_ext = '.' + self.settings['new_book_format'].lower() # check to see if destination format already exists - or if book is in database # if it does - mark the conversion task as complete and return a success # this will allow to send to E-Reader workflow to continue to work if os.path.isfile(file_path + format_new_ext) or\ local_db.get_book_format(self.book_id, self.settings['new_book_format']): log.info("Book id %d already converted to %s", book_id, format_new_ext) cur_book = local_db.get_book(book_id) self.title = cur_book.title self.results['path'] = cur_book.path self.results['title'] = self.title new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\ .filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none() if not new_format: new_format = db.Data(name=os.path.basename(file_path), book_format=self.settings['new_book_format'].upper(), book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext)) try: local_db.session.merge(new_format) local_db.session.commit() except SQLAlchemyError as e: local_db.session.rollback() log.error("Database error: %s", e) local_db.session.close() self._handleError(N_("Oops! Database Error: %(error)s.", error=e)) return self._handleSuccess() local_db.session.close() return os.path.basename(file_path + format_new_ext) else: log.info("Book id %d - target format of %s does not exist. Moving forward with convert.", book_id, format_new_ext) if config.config_kepubifypath and format_old_ext == '.epub' and format_new_ext == '.kepub': check, error_message = self._convert_kepubify(file_path, format_old_ext, format_new_ext) else: # check if calibre converter-executable is existing if not os.path.exists(config.config_converterpath): self._handleError(N_("Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath)) return has_cover = local_db.get_book(book_id).has_cover check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext, has_cover) if check == 0: cur_book = local_db.get_book(book_id) if os.path.isfile(file_path + format_new_ext): new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id) \ .filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none() if not new_format: new_format = db.Data(name=cur_book.data[0].name, book_format=self.settings['new_book_format'].upper(), book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext)) try: local_db.session.merge(new_format) local_db.session.commit() if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']: ub_session = init_db_thread() remove_synced_book(book_id, True, ub_session) ub_session.close() except SQLAlchemyError as e: local_db.session.rollback() log.error("Database error: %s", e) local_db.session.close() self._handleError(error_message) return self.results['path'] = cur_book.path self.title = cur_book.title self.results['title'] = self.title if not config.config_use_google_drive: self._handleSuccess() return os.path.basename(file_path + format_new_ext) else: error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper()) local_db.session.close() log.info("ebook converter failed with error while converting book") if not error_message: error_message = N_('Ebook converter failed with unknown error') else: log.error(error_message) self._handleError(error_message) return def _convert_kepubify(self, file_path, format_old_ext, format_new_ext): if config.config_embed_metadata and config.config_binariesdir: tmp_dir, temp_file_name = helper.do_calibre_export(self.book_id, format_old_ext[1:]) filename = os.path.join(tmp_dir, temp_file_name + format_old_ext) temp_file_path = tmp_dir else: filename = file_path + format_old_ext temp_file_path = os.path.dirname(file_path) quotes = [1, 3] command = [config.config_kepubifypath, filename, '-o', temp_file_path, '-i'] try: p = process_open(command, quotes) except OSError as e: return 1, N_("Kepubify-converter failed: %(error)s", error=e) self.progress = 0.01 while True: nextline = p.stdout.readlines() nextline = [x.strip('\n') for x in nextline if x != '\n'] for line in nextline: log.debug(line) if p.poll() is not None: break # process returncode check = p.returncode # move file if check == 0: converted_file = glob.glob(glob.escape(os.path.splitext(filename)[0]) + "*.kepub.epub") if len(converted_file) == 1: copyfile(converted_file[0], (file_path + format_new_ext)) os.unlink(converted_file[0]) else: return 1, N_("Converted file not found or more than one file in folder %(folder)s", folder=os.path.dirname(file_path)) return check, None def _convert_calibre(self, file_path, format_old_ext, format_new_ext, has_cover): path_tmp_opf = None try: # path_tmp_opf = self._embed_metadata() if config.config_embed_metadata: quotes = [5] tmp_dir = get_temp_dir() calibredb_binarypath = os.path.join(config.config_binariesdir, SUPPORTED_CALIBRE_BINARIES["calibredb"]) my_env = os.environ.copy() if config.config_calibre_split: my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db") library_path = config.config_calibre_split_dir else: library_path = config.config_calibre_dir opf_command = [calibredb_binarypath, 'show_metadata', '--as-opf', str(self.book_id), '--with-library', library_path] p = process_open(opf_command, quotes, my_env, newlines=False) lines = list() while p.poll() is None: lines.append(p.stdout.readline()) check = p.returncode calibre_traceback = p.stderr.readlines() if check == 0: path_tmp_opf = os.path.join(tmp_dir, "metadata_" + str(uuid4()) + ".opf") with open(path_tmp_opf, 'wb') as fd: fd.write(b''.join(lines)) else: error_message = "" for ele in calibre_traceback: if not ele.startswith('Traceback') and not ele.startswith(' File'): error_message = N_("Calibre failed with error: %(error)s", error=ele) return check, error_message quotes = [1, 2] quotes_index = 3 command = [config.config_converterpath, (file_path + format_old_ext), (file_path + format_new_ext)] if config.config_embed_metadata: quotes.append(4) quotes_index = 5 command.extend(['--from-opf', path_tmp_opf]) if has_cover: quotes.append(6) command.extend(['--cover', os.path.join(os.path.dirname(file_path), 'cover.jpg')]) quotes_index = 7 if config.config_calibre: parameters = re.findall(r"(--[\w-]+)(?:(\s(?:(\".+\")|(?:.+?)))(?:\s|$))?", config.config_calibre, re.IGNORECASE | re.UNICODE) if parameters: for param in parameters: command.append(strip_whitespaces(param[0])) quotes_index += 1 if param[1] != "": parsed = strip_whitespaces(param[1]).strip("\"") command.append(parsed) quotes.append(quotes_index) quotes_index += 1 p = process_open(command, quotes, newlines=False) except OSError as e: return 1, N_("Ebook-converter failed: %(error)s", error=e) while p.poll() is None: nextline = p.stdout.readline() if isinstance(nextline, bytes): nextline = nextline.decode('utf-8', errors="ignore").strip('\r\n') if nextline: log.debug(nextline) # parse progress string from calibre-converter progress = re.search(r"(\d+)%\s.*", nextline) if progress: self.progress = int(progress.group(1)) / 100 if config.config_use_google_drive: self.progress *= 0.9 # process returncode check = p.returncode calibre_traceback = p.stderr.readlines() error_message = "" for ele in calibre_traceback: ele = ele.decode('utf-8', errors="ignore").strip('\n') log.debug(ele) if not ele.startswith('Traceback') and not ele.startswith(' File'): error_message = N_("Calibre failed with error: %(error)s", error=ele) return check, error_message @property def name(self): return N_("Convert") def __str__(self): if self.ereader_mail: return "Convert Book {} and mail it to {}".format(self.book_id, self.ereader_mail) else: return "Convert Book {}".format(self.book_id) @property def is_cancellable(self): return False