Skip to content

Commit bb0335e

Browse files
committed
Add on-demand conversion to ePub to support in browser reading of other formats
1 parent 4e67a56 commit bb0335e

File tree

6 files changed

+188
-33
lines changed

6 files changed

+188
-33
lines changed

cps/config_sql.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22

33
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
4-
# Copyright (C) 2019 OzzieIsaacs, pwr
4+
# Copyright (C) 2019-2025 OzzieIsaacs, pwr, akharlamov
55
#
66
# This program is free software: you can redistribute it and/or modify
77
# it under the terms of the GNU General Public License as published by
@@ -19,7 +19,9 @@
1919
import os
2020
import sys
2121
import json
22+
from typing import Sequence
2223

24+
from packaging.tags import android_platforms
2325
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
2426
from sqlalchemy.exc import OperationalError
2527
from sqlalchemy.sql.expression import text
@@ -39,6 +41,7 @@
3941

4042
log = logger.create()
4143
_Base = declarative_base()
44+
CONVERSION_SOURCE_FMT_PRIORITY = ('FB2', 'FBZ', 'MOBI', 'PRC', 'TXT', 'TXTZ')
4245

4346

4447
class _Flask_Settings(_Base):
@@ -419,6 +422,14 @@ def __setattr__(self, attr_name, attr_value):
419422
super().__setattr__(attr_name, attr_value)
420423
self.__dict__["dirty"].append(attr_name)
421424

425+
def convertable_to(self, fmt: str) -> Sequence[str]:
426+
if not self.config_converterpath:
427+
return list()
428+
429+
convertable_formats = {
430+
'EPUB': CONVERSION_SOURCE_FMT_PRIORITY
431+
}
432+
return convertable_formats.get(fmt.upper(), [])
422433

423434
def _encrypt_fields(session, secret_key):
424435
try:

cps/helper.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# -*- coding: utf-8 -*-
22

33
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
4-
# Copyright (C) 2012-2019 cervinko, idalin, SiphonSquirrel, ouzklcn, akushsky,
5-
# OzzieIsaacs, bodybybuddha, jkrehm, matthazinski, janeczku
4+
# Copyright (C) 2012-2025 cervinko, idalin, SiphonSquirrel, ouzklcn, akushsky,
5+
# OzzieIsaacs, bodybybuddha, jkrehm, matthazinski, janeczku,
6+
# akharlamov
67
#
78
# This program is free software: you can redistribute it and/or modify
89
# it under the terms of the GNU General Public License as published by
@@ -200,11 +201,13 @@ def check_send_to_ereader(entry):
200201
# list with supported formats
201202
def check_read_formats(entry):
202203
extensions_reader = {'TXT', 'PDF', 'EPUB', 'KEPUB', 'CBZ', 'CBT', 'CBR', 'DJVU', 'DJV'}
204+
if config.config_converterpath:
205+
extensions_reader = {'FB2', 'FBZ', 'MOBI', *extensions_reader}
206+
203207
book_formats = list()
204-
if len(entry.data):
205-
for ele in iter(entry.data):
206-
if ele.format.upper() in extensions_reader:
207-
book_formats.append(ele.format.lower())
208+
for ele in iter(entry.data):
209+
if ele.format.upper() in extensions_reader:
210+
book_formats.append(ele.format.lower())
208211
return book_formats
209212

210213

cps/services/worker.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22

33
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
4-
# Copyright (C) 2020 pwr
4+
# Copyright (C) 2020-2025 pwr, akharlamov
55
#
66
# This program is free software: you can redistribute it and/or modify
77
# it under the terms of the GNU General Public License as published by
@@ -78,7 +78,7 @@ def __init__(self):
7878

7979
self.dequeued = list()
8080

81-
self.doLock = threading.Lock()
81+
self.doLock = threading.RLock()
8282
self.queue = ImprovedQueue()
8383
self.num = 0
8484
self.start()
@@ -89,13 +89,9 @@ def add(cls, user, task, hidden=False):
8989
ins.num += 1
9090
username = user if user is not None else 'System'
9191
log.debug("Add Task for user: {} - {}".format(username, task))
92-
ins.queue.put(QueuedTask(
93-
num=ins.num,
94-
user=username,
95-
added=datetime.now(),
96-
task=task,
97-
hidden=hidden
98-
))
92+
queued_task = QueuedTask(num=ins.num, user=username, added=datetime.now(), task=task, hidden=hidden)
93+
ins.queue.put(queued_task)
94+
return queued_task
9995

10096
@property
10197
def tasks(self):
@@ -160,6 +156,15 @@ def end_task(self, task_id):
160156
if str(task.id) == str(task_id) and task.is_cancellable:
161157
task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED
162158

159+
def get_task(self, task_id):
160+
ins = self.get_instance()
161+
with self.doLock:
162+
for __, __, __, task, __ in ins.tasks:
163+
if str(task.id) == str(task_id):
164+
return task
165+
return None
166+
167+
163168

164169
class CalibreTask:
165170
__metaclass__ = abc.ABCMeta

cps/static/js/refresh.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/* This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
2+
* Copyright (C) 2025 akharlamov
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
function incrementProgress() {
19+
let pb = document.getElementById('progressbar');
20+
let val = parseInt(pb.getAttribute('aria-valuenow') || '0') + 1;
21+
let w = Math.round(100 * val / parseInt(pb.getAttribute('aria-valuemax') || '10'));
22+
23+
pb.setAttribute('style', `width:${w}%`);
24+
pb.setAttribute('aria-valuenow', `${val}`);
25+
}
26+
27+
$(document).ready(function () {
28+
setTimeout(function () {
29+
location.reload();
30+
}, 5000);
31+
setInterval(incrementProgress, 1000);
32+
}
33+
);

cps/templates/converting.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{% extends "layout.html" %}
2+
{% block header %}
3+
<link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
4+
{% endblock %}
5+
{% block body %}
6+
<div class="discover">
7+
<h2>{{_('Converting e-book')}}</h2>
8+
<div>{{ entry.authors[0].name }}, {{ entry.title }}</div>
9+
<div class="progress">
10+
<div id="progressbar" class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="5"></div>
11+
</div>
12+
<a href="{{ url_for('web.read_book', book_id=book_id, book_format='epub') }}">
13+
Press here if the book does not open
14+
</a>
15+
</div>
16+
{% endblock %}
17+
{% block js %}
18+
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
19+
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-locale-all.min.js') }}"></script>
20+
<script src="{{ url_for('static', filename='js/table.js') }}"></script>
21+
<script src="{{ url_for('static', filename='js/refresh.js') }}"></script>
22+
{% endblock %}

cps/web.py

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
2-
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
2+
# Copyright (C) 2018-2025 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
33
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
44
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
55
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
6-
# apetresc, nanu-c, mutschler
6+
# apetresc, nanu-c, mutschler, akharlamov
77
#
88
# This program is free software: you can redistribute it and/or modify
99
# it under the terms of the GNU General Public License as published by
@@ -21,6 +21,9 @@
2121
import os
2222
import json
2323
import mimetypes
24+
import uuid
25+
from pathlib import Path
26+
2427
import chardet # dependency of requests
2528
import copy
2629
from importlib.metadata import metadata
@@ -29,6 +32,8 @@
2932
from flask import session as flask_session
3033
from flask_babel import gettext as _
3134
from flask_babel import get_locale
35+
36+
from .config_sql import CONVERSION_SOURCE_FMT_PRIORITY
3237
from .cw_login import login_user, logout_user, current_user
3338
from flask_limiter import RateLimitExceeded
3439
from flask_limiter.util import get_remote_address
@@ -47,10 +52,11 @@
4752
from .helper import check_valid_domain, check_email, check_username, \
4853
get_book_cover, get_series_cover_thumbnail, get_download_link, send_mail, generate_random_password, \
4954
send_registration_mail, check_send_to_ereader, check_read_formats, tags_filters, reset_password, valid_email, \
50-
edit_book_read_status, valid_password
55+
edit_book_read_status, valid_password, convert_book_format
5156
from .pagination import Pagination
5257
from .redirect import get_redirect_location
5358
from .cw_babel import get_available_locale
59+
from .tasks.convert import TaskConvert
5460
from .usermanagement import login_required_if_no_ano
5561
from .kobo_sync_status import remove_synced_book
5662
from .render_template import render_title_template
@@ -1566,6 +1572,39 @@ def profile():
15661572
# ###################################Show single book ##################################################################
15671573

15681574

1575+
def _start_epub_convert_task(book_id, filepath, source_format):
1576+
lib_dir = config.config_calibre_split_dir if config.config_calibre_split else config.config_calibre_dir
1577+
task = TaskConvert(
1578+
str(Path(lib_dir) / filepath),
1579+
book_id,
1580+
f'Converting {book_id} to EPUB',
1581+
{
1582+
'old_book_format': source_format,
1583+
'new_book_format': 'EPUB',
1584+
},
1585+
None,
1586+
user=current_user.name,
1587+
)
1588+
queued_task = WorkerThread.add(current_user, task)
1589+
return queued_task.task.id
1590+
1591+
1592+
def _select_conversion_source(book):
1593+
cur_candidate = None
1594+
cur_name = None
1595+
cur_priority = -1
1596+
for data in book.data:
1597+
if data.format in CONVERSION_SOURCE_FMT_PRIORITY:
1598+
priority = CONVERSION_SOURCE_FMT_PRIORITY.index(data.format)
1599+
else:
1600+
priority = -1
1601+
if priority > cur_priority:
1602+
cur_candidate = data.format
1603+
cur_name = data.name
1604+
cur_priority = priority
1605+
return cur_candidate, cur_name
1606+
1607+
15691608
@web.route("/read/<int:book_id>/<book_format>")
15701609
@login_required_if_no_ano
15711610
@viewer_required
@@ -1586,20 +1625,28 @@ def read_book(book_id, book_format):
15861625
bookmark = ub.session.query(ub.Bookmark).filter(and_(ub.Bookmark.user_id == int(current_user.id),
15871626
ub.Bookmark.book_id == book_id,
15881627
ub.Bookmark.format == book_format.upper())).first()
1589-
if book_format.lower() == "epub" or book_format.lower() == "kepub":
1628+
format_id = book_format.lower()
1629+
if format_id in ["epub", "kepub"]:
15901630
log.debug("Start [k]epub reader for %d", book_id)
15911631
return render_title_template('read.html', bookid=book_id, title=book.title, bookmark=bookmark,
15921632
book_format=book_format)
1593-
elif book_format.lower() == "pdf":
1633+
elif format_id == "pdf":
15941634
log.debug("Start pdf reader for %d", book_id)
15951635
return render_title_template('readpdf.html', pdffile=book_id, title=book.title)
1596-
elif book_format.lower() == "txt":
1636+
elif format_id == "txt":
15971637
log.debug("Start txt reader for %d", book_id)
15981638
return render_title_template('readtxt.html', txtfile=book_id, title=book.title)
1599-
elif book_format.lower() in ["djvu", "djv"]:
1639+
elif format_id in ["djvu", "djv"]:
16001640
log.debug("Start djvu reader for %d", book_id)
16011641
return render_title_template('readdjvu.html', djvufile=book_id, title=book.title,
1602-
extension=book_format.lower())
1642+
extension=format_id)
1643+
elif book_format.upper() in config.convertable_to('EPUB'):
1644+
log.debug("Start conversion to epub for %d", book_id)
1645+
src_format, name = _select_conversion_source(book)
1646+
task_id = _start_epub_convert_task(book_id, Path(book.path) / name, src_format)
1647+
1648+
url = url_for('web.show_book_converting', book_id=book_id, task_id=task_id)
1649+
return redirect(url)
16031650
else:
16041651
for fileExt in constants.EXTENSIONS_AUDIO:
16051652
if book_format.lower() == fileExt:
@@ -1623,10 +1670,7 @@ def read_book(book_id, book_format):
16231670
category="error")
16241671
return redirect(url_for("web.index"))
16251672

1626-
1627-
@web.route("/book/<int:book_id>")
1628-
@login_required_if_no_ano
1629-
def show_book(book_id):
1673+
def _get_book_entry(book_id):
16301674
entries = calibre_db.get_book_read_archived(book_id, config.config_read_column, allow_show_archived=True)
16311675
if entries:
16321676
read_book = entries[1]
@@ -1660,15 +1704,52 @@ def show_book(book_id):
16601704
if media_format.format.lower() in constants.EXTENSIONS_AUDIO:
16611705
entry.audio_entries.append(media_format.format.lower())
16621706

1707+
return entry, book_in_shelves, cc
1708+
else:
1709+
return None, None, None
1710+
1711+
1712+
@web.route("/book/<int:book_id>")
1713+
@login_required_if_no_ano
1714+
def show_book(book_id):
1715+
entry, shelves, cc = _get_book_entry(book_id)
1716+
if entry:
16631717
return render_title_template('detail.html',
16641718
entry=entry,
16651719
cc=cc,
16661720
is_xhr=request.headers.get('X-Requested-With') == 'XMLHttpRequest',
16671721
title=entry.title,
1668-
books_shelfs=book_in_shelves,
1722+
books_shelfs=shelves,
16691723
page="book")
16701724
else:
1671-
log.debug("Selected book is unavailable. File does not exist or is not accessible")
1672-
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
1673-
category="error")
1674-
return redirect(url_for("web.index"))
1725+
return _no_book_entry()
1726+
1727+
1728+
def _no_book_entry():
1729+
log.debug("Selected book is unavailable. File does not exist or is not accessible")
1730+
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
1731+
category="error")
1732+
return redirect(url_for("web.index"))
1733+
1734+
1735+
@web.route("/book/<int:book_id>/converting/<task_id>")
1736+
@login_required_if_no_ano
1737+
def show_book_converting(book_id, task_id):
1738+
task_uuid = uuid.UUID(task_id)
1739+
task = WorkerThread.get_instance().get_task(task_uuid)
1740+
complete = task and task.dead
1741+
if complete:
1742+
return redirect(url_for("web.read_book", book_id=book_id, book_format='epub'))
1743+
else:
1744+
entry = _get_book_entry(book_id)
1745+
if entry:
1746+
return render_title_template(
1747+
"converting.html",
1748+
entry=entry[0],
1749+
book_id=book_id,
1750+
task_id=task_id,
1751+
)
1752+
else:
1753+
return _no_book_entry()
1754+
1755+

0 commit comments

Comments
 (0)