feat: Implement EPUB reader modal with navigation and audio playback features

This commit is contained in:
JB
2025-10-10 06:48:59 -07:00
parent e37ef99856
commit e7408a06b8
6 changed files with 932 additions and 13 deletions
+385 -11
View File
@@ -4,13 +4,17 @@ import io
import json import json
import mimetypes import mimetypes
import os import os
import posixpath
import re import re
import threading import threading
import time import time
import uuid import uuid
import zipfile
from datetime import datetime from datetime import datetime
from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
from xml.etree import ElementTree as ET
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -96,6 +100,281 @@ _SPEAKER_MODE_VALUES = {option["value"] for option in _SPEAKER_MODE_OPTIONS}
_DEFAULT_ANALYSIS_THRESHOLD = 3 _DEFAULT_ANALYSIS_THRESHOLD = 3
def _coerce_path(value: Any) -> Optional[Path]:
if isinstance(value, Path):
return value
if isinstance(value, str):
candidate = Path(value)
return candidate
return None
def _normalize_epub_path(base_dir: str, href: str) -> str:
if not href:
return ""
sanitized = href.split("#", 1)[0].split("?", 1)[0].strip()
sanitized = sanitized.replace("\\", "/")
if not sanitized:
return ""
if sanitized.startswith("/"):
sanitized = sanitized[1:]
base_dir = ""
base = base_dir.strip("/")
combined = posixpath.join(base, sanitized) if base else sanitized
normalized = posixpath.normpath(combined)
if normalized in {"", "."}:
return ""
if normalized.startswith("../") or normalized == "..":
return ""
return normalized
def _decode_text(payload: bytes) -> str:
for encoding in ("utf-8", "utf-16", "windows-1252"):
try:
return payload.decode(encoding)
except UnicodeDecodeError:
continue
return payload.decode("utf-8", "ignore")
class _NavMapParser(HTMLParser):
def __init__(self, base_dir: str) -> None:
super().__init__()
self._base_dir = base_dir
self._in_nav = False
self._nav_depth = 0
self._current_href: Optional[str] = None
self._buffer: List[str] = []
self.links: Dict[str, str] = {}
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
tag_lower = tag.lower()
if tag_lower == "nav":
attributes = dict(attrs)
nav_type = (attributes.get("epub:type") or attributes.get("type") or "").strip().lower()
nav_role = (attributes.get("role") or "").strip().lower()
type_tokens = {token.strip() for token in nav_type.split() if token}
role_tokens = {token.strip() for token in nav_role.split() if token}
if "toc" in type_tokens or "doc-toc" in role_tokens:
self._in_nav = True
self._nav_depth = 1
return
if self._in_nav:
self._nav_depth += 1
return
if not self._in_nav:
return
if tag_lower == "a":
attributes = dict(attrs)
href = attributes.get("href") or ""
normalized = _normalize_epub_path(self._base_dir, href)
if normalized:
self._current_href = normalized
self._buffer = []
def handle_endtag(self, tag: str) -> None:
tag_lower = tag.lower()
if tag_lower == "nav" and self._in_nav:
self._nav_depth -= 1
if self._nav_depth <= 0:
self._in_nav = False
return
if not self._in_nav:
return
if tag_lower == "a" and self._current_href:
text = "".join(self._buffer).strip()
if text:
self.links.setdefault(self._current_href, text)
self._current_href = None
self._buffer = []
def handle_data(self, data: str) -> None:
if self._in_nav and self._current_href and data:
self._buffer.append(data)
def _parse_nav_document(payload: bytes, base_dir: str) -> Dict[str, str]:
parser = _NavMapParser(base_dir)
parser.feed(_decode_text(payload))
parser.close()
return parser.links
def _parse_ncx_document(payload: bytes, base_dir: str) -> Dict[str, str]:
try:
root = ET.fromstring(payload)
except ET.ParseError:
return {}
nav_map: Dict[str, str] = {}
for nav_point in root.findall(".//{*}navPoint"):
content = nav_point.find(".//{*}content")
if content is None:
continue
src = content.attrib.get("src", "")
normalized = _normalize_epub_path(base_dir, src)
if not normalized:
continue
label_el = nav_point.find(".//{*}text")
label = (label_el.text or "").strip() if label_el is not None and label_el.text else ""
if not label:
label = posixpath.basename(normalized) or f"Section {len(nav_map) + 1}"
nav_map.setdefault(normalized, label)
return nav_map
def _extract_epub_chapters(epub_path: Path) -> List[Dict[str, str]]:
chapters: List[Dict[str, str]] = []
if not epub_path or not epub_path.exists():
return chapters
try:
with zipfile.ZipFile(epub_path, "r") as archive:
container_bytes = archive.read("META-INF/container.xml")
container_root = ET.fromstring(container_bytes)
rootfile = container_root.find(".//{*}rootfile")
if rootfile is None:
return chapters
opf_path = (rootfile.attrib.get("full-path") or "").strip()
if not opf_path:
return chapters
opf_dir = posixpath.dirname(opf_path)
opf_bytes = archive.read(opf_path)
opf_root = ET.fromstring(opf_bytes)
manifest: Dict[str, Dict[str, str]] = {}
for item in opf_root.findall(".//{*}manifest/{*}item"):
item_id = item.attrib.get("id")
href = item.attrib.get("href")
if not item_id or not href:
continue
manifest[item_id] = {
"href": _normalize_epub_path(opf_dir, href),
"properties": item.attrib.get("properties", ""),
"media_type": item.attrib.get("media-type", ""),
}
spine_hrefs: List[str] = []
nav_id: Optional[str] = None
spine = opf_root.find(".//{*}spine")
if spine is not None:
nav_id = spine.attrib.get("toc")
for itemref in spine.findall(".//{*}itemref"):
idref = itemref.attrib.get("idref")
if not idref:
continue
entry = manifest.get(idref)
if not entry:
continue
href = entry["href"]
if href and href not in spine_hrefs:
spine_hrefs.append(href)
nav_href: Optional[str] = None
for entry in manifest.values():
properties = entry.get("properties") or ""
if "nav" in {token.strip() for token in properties.split() if token}:
nav_href = entry["href"]
break
if not nav_href and nav_id:
toc_entry = manifest.get(nav_id)
if toc_entry:
nav_href = toc_entry["href"]
nav_titles: Dict[str, str] = {}
if nav_href:
nav_base = posixpath.dirname(nav_href)
try:
nav_bytes = archive.read(nav_href)
except KeyError:
nav_bytes = None
if nav_bytes is not None:
if nav_href.lower().endswith(".ncx"):
nav_titles = _parse_ncx_document(nav_bytes, nav_base)
else:
nav_titles = _parse_nav_document(nav_bytes, nav_base)
if not nav_titles and nav_id and nav_id in manifest:
toc_entry = manifest[nav_id]
nav_base = posixpath.dirname(toc_entry["href"])
try:
nav_bytes = archive.read(toc_entry["href"])
except KeyError:
nav_bytes = None
if nav_bytes is not None:
nav_titles = _parse_ncx_document(nav_bytes, nav_base)
for index, href in enumerate(spine_hrefs, start=1):
normalized = _normalize_epub_path("", href)
if not normalized:
continue
title = (
nav_titles.get(normalized)
or nav_titles.get(normalized.split("#", 1)[0])
or posixpath.basename(normalized)
or f"Chapter {index}"
)
chapters.append({"href": normalized, "title": title})
if not chapters and nav_titles:
for index, (href, title) in enumerate(nav_titles.items(), start=1):
normalized = _normalize_epub_path("", href)
if not normalized:
continue
label = title or posixpath.basename(normalized) or f"Chapter {index}"
chapters.append({"href": normalized, "title": label})
return chapters
except (FileNotFoundError, zipfile.BadZipFile, KeyError, ET.ParseError, UnicodeDecodeError):
return []
return chapters
def _read_epub_bytes(epub_path: Path, raw_href: str) -> bytes:
normalized = _normalize_epub_path("", raw_href)
if not normalized:
raise ValueError("Invalid resource path")
with zipfile.ZipFile(epub_path, "r") as archive:
return archive.read(normalized)
def _locate_job_epub(job: Job) -> Optional[Path]:
result = getattr(job, "result", None)
if result is not None:
primary = _coerce_path(getattr(result, "epub_path", None))
if primary and primary.exists():
return primary
artifacts = getattr(result, "artifacts", None)
if isinstance(artifacts, Mapping):
for value in artifacts.values():
candidate = _coerce_path(value)
if candidate and candidate.suffix.lower() == ".epub" and candidate.exists():
return candidate
return None
def _locate_job_audio(job: Job) -> Optional[Path]:
result = getattr(job, "result", None)
candidates: List[Path] = []
if result is not None:
primary = _coerce_path(getattr(result, "audio_path", None))
if primary:
candidates.append(primary)
artifacts = getattr(result, "artifacts", None)
if isinstance(artifacts, Mapping):
for value in artifacts.values():
candidate = _coerce_path(value)
if candidate:
candidates.append(candidate)
for candidate in candidates:
if candidate and candidate.exists() and candidate.suffix.lower() in {".mp3", ".wav", ".flac", ".ogg", ".opus", ".m4a"}:
return candidate
for candidate in candidates:
if candidate and candidate.exists():
return candidate
return None
def _build_narrator_roster( def _build_narrator_roster(
voice: str, voice: str,
voice_profile: Optional[str], voice_profile: Optional[str],
@@ -2288,26 +2567,121 @@ def clear_finished_jobs() -> ResponseReturnValue:
return redirect(url_for("web.index", _anchor="queue")) return redirect(url_for("web.index", _anchor="queue"))
@web_bp.get("/jobs/<job_id>/epub")
def job_epub(job_id: str) -> ResponseReturnValue:
job = _service().get_job(job_id)
if job is None or job.status != JobStatus.COMPLETED:
abort(404)
epub_path = _locate_job_epub(job)
if not epub_path:
abort(404)
return send_file(
epub_path,
mimetype="application/epub+zip",
as_attachment=False,
download_name=epub_path.name,
conditional=True,
)
@web_bp.get("/jobs/<job_id>/audio-stream")
def job_audio_stream(job_id: str) -> ResponseReturnValue:
job = _service().get_job(job_id)
if job is None or job.status != JobStatus.COMPLETED:
abort(404)
audio_path = _locate_job_audio(job)
if not audio_path:
abort(404)
mime_type, _ = mimetypes.guess_type(str(audio_path))
return send_file(
audio_path,
mimetype=mime_type or "audio/mpeg",
as_attachment=False,
conditional=True,
)
@web_bp.get("/jobs/<job_id>/reader")
def job_reader(job_id: str) -> ResponseReturnValue:
job = _service().get_job(job_id)
if job is None or job.status != JobStatus.COMPLETED:
abort(404)
epub_path = _locate_job_epub(job)
if not epub_path:
abort(404)
chapters = _extract_epub_chapters(epub_path)
audio_path = _locate_job_audio(job)
chapter_url = url_for("web.job_reader_chapter", job_id=job.id)
asset_base = url_for("web.job_reader_asset", job_id=job.id, asset_path="").rstrip("/") + "/"
audio_url = url_for("web.job_audio_stream", job_id=job.id) if audio_path else ""
epub_url = url_for("web.job_epub", job_id=job.id)
return render_template(
"reader_embed.html",
job=job,
audio_url=audio_url,
epub_url=epub_url,
chapters=chapters,
chapter_url=chapter_url,
asset_base=asset_base,
)
@web_bp.get("/jobs/<job_id>/reader/chapter")
def job_reader_chapter(job_id: str) -> ResponseReturnValue:
job = _service().get_job(job_id)
if job is None or job.status != JobStatus.COMPLETED:
abort(404)
epub_path = _locate_job_epub(job)
if not epub_path:
abort(404)
raw_href = request.args.get("href", "").strip()
if not raw_href:
abort(400)
try:
chapter_bytes = _read_epub_bytes(epub_path, raw_href)
except (ValueError, FileNotFoundError, KeyError):
abort(404)
content = _decode_text(chapter_bytes)
return jsonify({"content": content})
@web_bp.get("/jobs/<job_id>/reader/asset/<path:asset_path>")
def job_reader_asset(job_id: str, asset_path: str) -> ResponseReturnValue:
job = _service().get_job(job_id)
if job is None or job.status != JobStatus.COMPLETED:
abort(404)
epub_path = _locate_job_epub(job)
if not epub_path:
abort(404)
try:
payload = _read_epub_bytes(epub_path, asset_path)
except (ValueError, FileNotFoundError, KeyError):
abort(404)
mime_type, _ = mimetypes.guess_type(asset_path)
buffer = io.BytesIO(payload)
buffer.seek(0)
return send_file(
buffer,
mimetype=mime_type or "application/octet-stream",
as_attachment=False,
download_name=posixpath.basename(asset_path) or "asset",
)
@web_bp.get("/jobs/<job_id>/download") @web_bp.get("/jobs/<job_id>/download")
def download_job(job_id: str) -> ResponseReturnValue: def download_job(job_id: str) -> ResponseReturnValue:
job = _service().get_job(job_id) job = _service().get_job(job_id)
if job is None or job.status != JobStatus.COMPLETED: if job is None or job.status != JobStatus.COMPLETED:
abort(404) abort(404)
result = getattr(job, "result", None) audio_path = _locate_job_audio(job)
audio_path = getattr(result, "audio_path", None) if not audio_path:
if audio_path is None:
abort(404) abort(404)
if not isinstance(audio_path, Path): # pragma: no cover - sanity guard mime_type, _ = mimetypes.guess_type(str(audio_path))
abort(404)
audio_path_path = cast(Path, audio_path)
if not audio_path_path.exists():
abort(404)
mime_type, _ = mimetypes.guess_type(str(audio_path_path))
return send_file( return send_file(
audio_path_path, audio_path,
mimetype=mime_type or "application/octet-stream", mimetype=mime_type or "application/octet-stream",
as_attachment=True, as_attachment=True,
download_name=audio_path_path.name, download_name=audio_path.name,
) )
+73 -1
View File
@@ -1,6 +1,11 @@
const initDashboard = () => { const initDashboard = () => {
const uploadModal = document.querySelector('[data-role="upload-modal"]'); const uploadModal = document.querySelector('[data-role="upload-modal"]');
const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]'); const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
const readerModal = document.querySelector('[data-role="reader-modal"]');
const readerFrame = readerModal?.querySelector('[data-role="reader-frame"]') || null;
const readerHint = readerModal?.querySelector('[data-role="reader-modal-hint"]') || null;
const readerTitle = readerModal?.querySelector('#reader-modal-title') || null;
const defaultReaderHint = readerHint?.textContent || "";
const scope = uploadModal || document; const scope = uploadModal || document;
const profileSelect = scope.querySelector('[data-role="voice-profile"]'); const profileSelect = scope.querySelector('[data-role="voice-profile"]');
@@ -11,6 +16,7 @@ const initDashboard = () => {
const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language"); const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language");
let lastTrigger = null; let lastTrigger = null;
let readerTrigger = null;
const openUploadModal = (trigger) => { const openUploadModal = (trigger) => {
if (!uploadModal) return; if (!uploadModal) return;
@@ -36,6 +42,51 @@ const initDashboard = () => {
} }
}; };
const openReaderModal = (trigger) => {
if (!readerModal || !readerFrame) return;
const url = trigger?.dataset.readerUrl || "";
if (!url) return;
readerTrigger = trigger || null;
const bookTitle = trigger?.dataset.bookTitle || "";
if (readerTitle) {
readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen";
}
if (readerHint) {
readerHint.textContent = bookTitle ? `Preview ${bookTitle} directly in your browser.` : defaultReaderHint;
}
closeUploadModal();
readerModal.hidden = false;
readerModal.dataset.open = "true";
document.body.classList.add("modal-open");
readerFrame.src = url;
try {
readerFrame.focus({ preventScroll: true });
} catch (error) {
// Ignore focus errors when the browser blocks iframe focus
}
};
const closeReaderModal = () => {
if (!readerModal) return;
if (readerModal.hidden) return;
readerModal.hidden = true;
delete readerModal.dataset.open;
document.body.classList.remove("modal-open");
if (readerFrame) {
readerFrame.src = "about:blank";
}
if (readerHint) {
readerHint.textContent = defaultReaderHint;
}
if (readerTitle) {
readerTitle.textContent = "Read & listen";
}
if (readerTrigger && readerTrigger instanceof HTMLElement) {
readerTrigger.focus({ preventScroll: true });
}
readerTrigger = null;
};
openModalButtons.forEach((button) => { openModalButtons.forEach((button) => {
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
@@ -54,8 +105,29 @@ const initDashboard = () => {
} }
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && uploadModal && !uploadModal.hidden) { if (event.key === "Escape") {
if (uploadModal && !uploadModal.hidden) {
closeUploadModal(); closeUploadModal();
return;
}
if (readerModal && !readerModal.hidden) {
closeReaderModal();
}
}
});
document.addEventListener("click", (event) => {
const readerClose = event.target.closest('[data-role="reader-modal-close"]');
if (readerClose) {
event.preventDefault();
closeReaderModal();
return;
}
const readerTriggerBtn = event.target.closest('[data-role="open-reader"]');
if (readerTriggerBtn) {
event.preventDefault();
openReaderModal(readerTriggerBtn);
} }
}); });
+23
View File
@@ -233,6 +233,29 @@ body {
border-radius: 28px; border-radius: 28px;
} }
.reader-modal {
width: min(960px, 95vw);
height: min(90vh, 720px);
display: flex;
flex-direction: column;
}
.reader-modal__body {
padding: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
background: rgba(15, 23, 42, 0.75);
}
.reader-modal__frame {
width: 100%;
height: 100%;
border: none;
border-radius: 18px;
background: #0f172a;
}
.voice-browser__header { .voice-browser__header {
display: flex; display: flex;
align-items: center; align-items: center;
+20
View File
@@ -137,6 +137,26 @@
</div> </div>
</div> </div>
<div class="modal" data-role="reader-modal" hidden>
<div class="modal__overlay" data-role="reader-modal-close" tabindex="-1"></div>
<div class="modal__content card card--modal reader-modal" role="dialog" aria-modal="true" aria-labelledby="reader-modal-title">
<header class="modal__header">
<div class="modal__heading">
<h2 class="modal__title" id="reader-modal-title">Read &amp; listen</h2>
<p class="hint" data-role="reader-modal-hint">Preview synchronized narration directly in your browser.</p>
</div>
<button type="button" class="icon-button" data-role="reader-modal-close" aria-label="Close reader"></button>
</header>
<div class="modal__body reader-modal__body">
<iframe title="EPUB reader"
class="reader-modal__frame"
data-role="reader-frame"
allow="autoplay"
loading="lazy"></iframe>
</div>
</div>
</div>
<section class="card" id="queue"> <section class="card" id="queue">
<div id="jobs-panel" <div id="jobs-panel"
hx-get="{{ url_for('web.jobs_partial') }}" hx-get="{{ url_for('web.jobs_partial') }}"
+19
View File
@@ -75,6 +75,25 @@
{% if job.status == JobStatus.COMPLETED and job.result.audio_path %} {% if job.status == JobStatus.COMPLETED and job.result.audio_path %}
<a class="button button--ghost" href="{{ url_for('web.download_job', job_id=job.id) }}">Download</a> <a class="button button--ghost" href="{{ url_for('web.download_job', job_id=job.id) }}">Download</a>
{% endif %} {% endif %}
{% set reader_source = None %}
{% if job.status == JobStatus.COMPLETED and job.result %}
{% if job.result.epub_path %}
{% set reader_source = job.result.epub_path %}
{% elif job.result.artifacts and job.result.artifacts.get('epub3') %}
{% set reader_source = job.result.artifacts.get('epub3') %}
{% endif %}
{% endif %}
{% if reader_source %}
<button type="button"
class="icon-button"
data-role="open-reader"
data-reader-url="{{ url_for('web.job_reader', job_id=job.id) }}"
data-book-title="{{ job.original_filename }}"
title="Open reader"
aria-label="Open EPUB reader for {{ job.original_filename }}">
📖
</button>
{% endif %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.delete_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove this job from the list?">Remove</button> <button type="button" class="button button--ghost" hx-post="{{ url_for('web.delete_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove this job from the list?">Remove</button>
</div> </div>
</li> </li>
+411
View File
@@ -0,0 +1,411 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ job.original_filename }} · Reader</title>
<style>
:root {
color-scheme: dark;
--bg: #020617;
--panel: rgba(15, 23, 42, 0.92);
--border: rgba(148, 163, 184, 0.25);
--text: #e2e8f0;
--accent: #38bdf8;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
}
body {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
}
.reader-shell {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
gap: 0.75rem;
}
.reader-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 14px;
background: var(--panel);
border: 1px solid var(--border);
}
.reader-toolbar__title {
flex: 1 1 auto;
text-align: center;
font-weight: 600;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reader-toolbar button {
flex: 0 0 auto;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
color: var(--text);
border-radius: 12px;
padding: 0.45rem 0.9rem;
font-weight: 500;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.reader-toolbar button:hover,
.reader-toolbar button:focus-visible {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.4);
outline: none;
}
.reader-toolbar select {
flex: 1 1 30%;
min-width: 140px;
max-width: 320px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
color: var(--text);
padding: 0.4rem 0.75rem;
font-size: 0.95rem;
}
.reader-toolbar select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
#reader-container {
flex: 1 1 auto;
min-height: 0;
border-radius: 16px;
background: var(--panel);
border: 1px solid var(--border);
overflow: hidden;
display: flex;
}
#reader {
flex: 1 1 auto;
}
.reader-footer {
border-radius: 14px;
background: var(--panel);
border: 1px solid var(--border);
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reader-footer audio {
width: 100%;
}
.reader-status {
font-size: 0.85rem;
color: rgba(226, 232, 240, 0.75);
}
</style>
</head>
<body
data-chapter-url="{{ chapter_url }}"
data-asset-base="{{ asset_base }}"
>
<div class="reader-shell" data-role="reader-shell">
<div class="reader-toolbar">
<button type="button" data-action="prev">Previous</button>
<select data-role="reader-chapter" aria-label="Select chapter"></select>
<div class="reader-toolbar__title" data-role="reader-title">{{ job.original_filename }}</div>
<button type="button" data-action="next">Next</button>
</div>
<div id="reader-container">
<div id="reader"></div>
</div>
<div class="reader-footer">
{% if audio_url %}
<audio controls preload="metadata" src="{{ audio_url }}"></audio>
{% endif %}
<div class="reader-status" data-role="reader-status">Loading EPUB…</div>
</div>
</div>
<script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': job.original_filename}|tojson }}</script>
<script type="module">
const statusEl = document.querySelector('[data-role="reader-status"]');
const prevBtn = document.querySelector('[data-action="prev"]');
const nextBtn = document.querySelector('[data-action="next"]');
const titleEl = document.querySelector('[data-role="reader-title"]');
const readerEl = document.getElementById('reader');
const chapterSelect = document.querySelector('[data-role="reader-chapter"]');
const rawData = document.getElementById('reader-data');
const chapterUrl = document.body.dataset.chapterUrl || '';
const assetBase = document.body.dataset.assetBase || '';
let data = { chapters: [] };
try {
data = JSON.parse(rawData?.textContent || '{}');
} catch (error) {
data = { chapters: [] };
}
const chapters = Array.isArray(data.chapters) ? data.chapters : [];
const baseTitle = typeof data.title === 'string' && data.title ? data.title : 'Book';
let currentIndex = 0;
const parser = new DOMParser();
const activeStyleNodes = [];
const escapeFragment = (value) => {
if (!value) return '';
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return value.replace(/[^a-zA-Z0-9_-]/g, '\\$&');
};
const setStatus = (message) => {
if (statusEl) {
statusEl.textContent = message;
}
};
const resolveAsset = (value) => {
if (!value) return value;
const trimmed = value.trim();
if (/^(?:[a-z]+:)?\/\//i.test(trimmed) || trimmed.startsWith('data:')) {
return trimmed;
}
const base = window.location.origin + assetBase;
try {
const url = new URL(trimmed, base);
return url.pathname + url.search + url.hash;
} catch (error) {
const normalized = trimmed.replace(/^\.\//, '').replace(/^\/+/, '');
return `${assetBase}${normalized}`;
}
};
const rewriteAssets = (root) => {
root.querySelectorAll('script').forEach((node) => node.remove());
root.querySelectorAll('[src]').forEach((node) => {
const resolved = resolveAsset(node.getAttribute('src'));
if (resolved) {
node.setAttribute('src', resolved);
}
});
root.querySelectorAll('link[href]').forEach((node) => {
const resolved = resolveAsset(node.getAttribute('href'));
if (resolved) {
node.setAttribute('href', resolved);
}
});
};
const applyStyles = (root) => {
while (activeStyleNodes.length) {
const node = activeStyleNodes.pop();
node?.remove();
}
root.querySelectorAll('style').forEach((styleNode) => {
const clone = styleNode.cloneNode(true);
document.head.append(clone);
activeStyleNodes.push(clone);
styleNode.remove();
});
root.querySelectorAll('link[rel="stylesheet"]').forEach((linkNode) => {
const href = linkNode.getAttribute('href');
if (!href) {
linkNode.remove();
return;
}
const clone = document.createElement('link');
clone.rel = 'stylesheet';
clone.href = href;
document.head.append(clone);
activeStyleNodes.push(clone);
linkNode.remove();
});
};
const updateControls = () => {
const hasChapters = chapters.length > 0;
if (prevBtn) {
prevBtn.disabled = !hasChapters || currentIndex <= 0;
}
if (nextBtn) {
nextBtn.disabled = !hasChapters || currentIndex >= chapters.length - 1;
}
if (chapterSelect && hasChapters) {
chapterSelect.value = String(currentIndex);
}
if (titleEl) {
const current = chapters[currentIndex];
titleEl.textContent = current?.title ? `${current.title} · ${baseTitle}` : baseTitle;
}
};
const renderChapter = async (index, fragmentId = '') => {
if (!chapters[index]) {
setStatus('No chapter available.');
return;
}
setStatus('Loading chapter…');
currentIndex = index;
updateControls();
try {
const target = new URL(chapterUrl, window.location.origin);
target.searchParams.set('href', chapters[index].href);
const response = await fetch(target.toString(), { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
const content = payload?.content || '';
const doc = parser.parseFromString(content, 'text/html');
rewriteAssets(doc);
applyStyles(doc);
const fragment = document.createDocumentFragment();
const body = doc.body || doc.documentElement;
body.childNodes.forEach((node) => {
fragment.append(node.cloneNode(true));
});
readerEl?.replaceChildren(fragment);
if (readerEl) {
readerEl.scrollTop = 0;
if (fragmentId) {
const target = readerEl.querySelector(`#${escapeFragment(fragmentId)}`);
if (target instanceof HTMLElement) {
target.scrollIntoView({ block: 'start', behavior: 'auto' });
}
}
}
setStatus(`Chapter ${index + 1} of ${chapters.length}`);
updateControls();
} catch (error) {
console.error('Failed to load chapter', error);
setStatus('Unable to load this chapter. Try another one.');
}
};
const initChapters = () => {
chapterSelect?.replaceChildren();
if (!chapters.length) {
setStatus('No chapters found in this book.');
if (chapterSelect) {
const option = document.createElement('option');
option.textContent = 'No chapters';
option.value = '';
chapterSelect.append(option);
chapterSelect.disabled = true;
}
updateControls();
readerEl.textContent = 'This EPUB does not include any readable chapters.';
return;
}
chapterSelect?.removeAttribute('disabled');
chapters.forEach((chapter, idx) => {
const option = document.createElement('option');
option.value = String(idx);
option.textContent = chapter.title || `Chapter ${idx + 1}`;
chapterSelect?.append(option);
});
chapterSelect?.addEventListener('change', (event) => {
const target = event.target;
if (!(target instanceof HTMLSelectElement)) {
return;
}
const value = Number.parseInt(target.value, 10);
if (!Number.isNaN(value)) {
renderChapter(value);
}
});
prevBtn?.addEventListener('click', () => {
if (currentIndex > 0) {
renderChapter(currentIndex - 1);
}
});
nextBtn?.addEventListener('click', () => {
if (currentIndex < chapters.length - 1) {
renderChapter(currentIndex + 1);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') {
if (currentIndex > 0) {
renderChapter(currentIndex - 1);
}
} else if (event.key === 'ArrowRight') {
if (currentIndex < chapters.length - 1) {
renderChapter(currentIndex + 1);
}
}
});
renderChapter(0);
};
initChapters();
readerEl?.addEventListener('click', (event) => {
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
if (!anchor) {
return;
}
const href = anchor.getAttribute('href') || '';
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
return;
}
if (href.startsWith('#')) {
event.preventDefault();
const fragmentId = href.slice(1);
const target = readerEl.querySelector(`#${escapeFragment(fragmentId)}`);
if (target instanceof HTMLElement) {
target.scrollIntoView({ block: 'start', behavior: 'auto' });
}
return;
}
const [resource, fragment] = href.split('#', 2);
const matchIndex = chapters.findIndex((chapter) => {
const normalized = (chapter.href || '').split('#', 1)[0];
return normalized === resource;
});
if (matchIndex >= 0) {
event.preventDefault();
renderChapter(matchIndex, fragment || '');
}
});
</script>
</body>
</html>