diff --git a/src/calibre/srv/convert.py b/src/calibre/srv/convert.py index 74ab8ea317..a45defe098 100644 --- a/src/calibre/srv/convert.py +++ b/src/calibre/srv/convert.py @@ -4,9 +4,42 @@ from __future__ import absolute_import, division, print_function, unicode_literals -from calibre.srv.errors import BookNotFound +import os +import shutil +import tempfile +from functools import partial +from threading import Lock + +from calibre.srv.errors import BookNotFound, HTTPNotFound from calibre.srv.routes import endpoint, json from calibre.srv.utils import get_library_data +from calibre.utils.monotonic import monotonic + +receive_data_methods = {'GET', 'POST'} +conversion_jobs = {} +cache_lock = Lock() + + +class JobStatus(object): + + def __init__(self, job_id, tdir, library_id, pathtoebook, conversion_data): + self.job_id = job_id + self.tdir = tdir + self.library_id, self.pathtoebook = library_id, pathtoebook + self.conversion_data = conversion_data + self.running = self.ok = True + self.last_check_at = monotonic() + + def cleanup(self): + safe_delete_tree(self.tdir) + + +def expire_old_jobs(): + now = monotonic() + with cache_lock: + remove = [job_id for job_id, job_status in conversion_jobs.iteritems() if now - job_status.last_check_at >= 360] + for job_id in remove: + conversion_jobs.pop(job_id) def conversion_defaults(): @@ -17,6 +50,75 @@ def conversion_defaults(): return ans +def safe_delete_file(path): + try: + os.remove(path) + except EnvironmentError: + pass + + +def safe_delete_tree(path): + try: + shutil.rmtree(path, ignore_errors=True) + except EnvironmentError: + pass + + +def job_done(job): + with cache_lock: + try: + job_status = conversion_jobs[job.job_id] + except KeyError: + return + job_status.running = False + if job.failed: + job_status.ok = False + job_status.was_aborted = job.was_aborted + job_status.traceback = job.traceback + safe_delete_file(job_status.pathtoebook) + + +def queue_job(ctx, rd, library_id, copy_format_to, fmt, book_id, conversion_data): + tdir = tempfile.mkdtemp(dir=rd.tdir) + fd, pathtoebook = tempfile.mkstemp(prefix='', suffix=('.' + fmt.lower()), dir=tdir) + with os.fdopen(fd, 'wb') as f: + copy_format_to(f) + job_id = ctx.start_job( + 'Convert book %s (%s)' % (book_id, fmt), 'calibre.srv.convert_book', + 'convert_book', args=(pathtoebook, conversion_data), + job_done_callback=job_done + ) + expire_old_jobs() + with cache_lock: + conversion_jobs[job_id] = JobStatus(job_id, tdir, library_id, pathtoebook, conversion_data) + return job_id + + +@endpoint('/conversion/start/{book_id}', postprocess=json, needs_db_write=True, types={'book_id': int}, methods=receive_data_methods) +def start_conversion(ctx, rd, book_id): + db, library_id = get_library_data(ctx, rd)[:2] + if not ctx.has_id(rd, db, book_id): + raise BookNotFound(book_id, db) + data = json.loads(rd.request_body_file.read()) + input_fmt = data['input_fmt'] + job_id = queue_job(ctx, rd, library_id, partial(db.copy_format_to, book_id, input_fmt), input_fmt, book_id, data) + return job_id + + +@endpoint('/conversion/status/{job_id}', postprocess=json, needs_db_write=True, types={'job_id': int}) +def conversion_status(ctx, rd, job_id): + with cache_lock: + job_status = conversion_jobs.get(job_id) + if job_status is None: + raise HTTPNotFound('No job with id: {}'.format(job_id)) + job_status.last_check_at = monotonic() + if job_status.running: + pass + else: + del conversion_jobs[job_id] + job_status.cleanup() + + @endpoint('/conversion/book-data/{book_id}', postprocess=json, types={'book_id': int}) def conversion_data(ctx, rd, book_id): from calibre.ebooks.conversion.config import ( @@ -40,5 +142,6 @@ def conversion_data(ctx, rd, book_id): 'conversion_specifics': load_specifics(db, book_id), 'title': db.field_for('title', book_id), 'authors': db.field_for('authors', book_id), + 'book_id': book_id } return ans diff --git a/src/pyj/book_list/convert_book.pyj b/src/pyj/book_list/convert_book.pyj index c474e0f5ed..7d12bdb5ea 100644 --- a/src/pyj/book_list/convert_book.pyj +++ b/src/pyj/book_list/convert_book.pyj @@ -5,14 +5,15 @@ from __python__ import bound_methods, hash_literals from elementmaker import E from gettext import gettext as _ -from ajax import ajax +from ajax import ajax, ajax_send from book_list.book_details import report_load_failure from book_list.library_data import load_status, url_books_query from book_list.router import back, report_a_load_failure from book_list.top_bar import create_top_bar, set_title from book_list.ui import set_panel_handler -from dom import add_extra_css, build_rule, clear +from dom import add_extra_css, build_rule, clear, ensure_id from utils import conditional_timeout, parse_url_params +from widgets import create_button CLASS_NAME = 'convert-book-panel' @@ -24,21 +25,67 @@ add_extra_css(def(): conversion_data = None -conversion_data_load_status = {'loading':True, 'ok':False, 'error_html':None, 'current_fetch': None} +conversion_data_load_status = {'loading':True, 'ok':False, 'error_html':None, 'current_fetch':None} + + +def on_conversion_started(end_type, xhr, ev): + pass + + +def get_conversion_options(container): + ans = {} + for k in Object.keys(conversion_data.conversion_defaults): + if Object.prototype.hasOwnProperty.call(conversion_data.conversion_specifics, k): + ans[k] = conversion_data.conversion_specifics[k] + else: + ans[k] = conversion_data.conversion_defaults[k] + return ans + + +def start_conversion(): + container = document.getElementById(conversion_data.container_id) + data = { + 'input_fmt': container.querySelector('select[name="input_formats"]').value, + 'output_fmt': container.querySelector('select[name="output_formats"]').value, + 'options': get_conversion_options(container), + 'book_id': conversion_data.book_id, + } + query = url_books_query() + ajax_send(f'/conversion/start/{conversion_data.book_id}', data, on_conversion_started, query=query) def create_convert_book(container): - conversion_data.container_id = container.id + conversion_data.container_id = ensure_id(container) set_title(container.parentNode, _('Convert: {}').format(conversion_data.title)) - top = E.div(style="display: flex", + container.appendChild(E.div(class_='top')) + + def generate_choice(name): + formats = conversion_data[name] + ans = E.select(name=name) + for fmt in formats: + ans.appendChild(E.option(fmt, value=fmt)) + return ans + tcell = 'display: table-cell; padding-top: 1em; padding-left: 1em' + + start_conv = E.div( E.div( - _('Input format') + E.div( + style='display: table-row', + E.div(style=tcell, _('Input format:')), + E.div(generate_choice('input_formats'), style=tcell), + ), + E.div( + style='display: table-row', + E.div(style=tcell, _('Output format:')), + E.div(generate_choice('output_formats'), style=tcell), + ) ), E.div( - _('Output format') + style='margin: 1em', + create_button(_('Start conversion'), action=start_conversion) ) ) - container.appendChild(top) + container.lastChild.appendChild(start_conv) # Initialization {{{ @@ -52,7 +99,6 @@ def on_data_loaded(end_type, xhr, ev): def bad_load(msg): conversion_data_load_status.ok = False - conversion_data_load_status.loading = False conversion_data_load_status.error_html = msg or xhr.error_html if end_type is 'load':