diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 61d2fd8..024ccbb 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -13,7 +13,7 @@ import zipfile from datetime import datetime from html.parser import HTMLParser from pathlib import Path -from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast +from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple, cast from xml.etree import ElementTree as ET from flask import ( @@ -340,42 +340,121 @@ def _read_epub_bytes(epub_path: Path, raw_href: str) -> bytes: return archive.read(normalized) +def _iter_job_result_paths(job: Job) -> List[Path]: + result = getattr(job, "result", None) + if result is None: + return [] + resolved_seen: Set[Path] = set() + collected: List[Path] = [] + + def _remember(candidate: Optional[Path]) -> None: + if not candidate: + return + try: + resolved = candidate.resolve() + except OSError: + return + if resolved in resolved_seen: + return + resolved_seen.add(resolved) + collected.append(candidate) + + artifacts = getattr(result, "artifacts", None) + if isinstance(artifacts, Mapping): + for value in artifacts.values(): + candidate = _coerce_path(value) + if candidate and candidate.exists() and candidate.is_file(): + _remember(candidate) + + for attr in ("audio_path", "epub_path"): + candidate = _coerce_path(getattr(result, attr, None)) + if candidate and candidate.exists() and candidate.is_file(): + _remember(candidate) + + return collected + + +def _iter_job_artifact_dirs(job: Job) -> List[Path]: + result = getattr(job, "result", None) + if result is None: + return [] + artifacts = getattr(result, "artifacts", None) + directories: List[Path] = [] + if isinstance(artifacts, Mapping): + for value in artifacts.values(): + candidate = _coerce_path(value) + if candidate and candidate.exists() and candidate.is_dir(): + directories.append(candidate) + return directories + + +def _normalize_suffixes(suffixes: Iterable[str]) -> List[str]: + normalized: List[str] = [] + for suffix in suffixes: + if not suffix: + continue + cleaned = suffix.lower().strip() + if not cleaned: + continue + if not cleaned.startswith("."): + cleaned = f".{cleaned.lstrip('.')}" + normalized.append(cleaned) + return normalized + + +def _find_job_file(job: Job, suffixes: Iterable[str]) -> Optional[Path]: + ordered_suffixes = _normalize_suffixes(suffixes) + if not ordered_suffixes: + return None + files = _iter_job_result_paths(job) + for suffix in ordered_suffixes: + for candidate in files: + if candidate.suffix.lower() == suffix: + return candidate + directories = _iter_job_artifact_dirs(job) + for suffix in ordered_suffixes: + pattern = f"*{suffix}" + for directory in directories: + try: + match = next((path for path in directory.rglob(pattern) if path.is_file()), None) + except OSError: + match = None + if match: + return match + return None + + 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 + path = _find_job_file(job, [".epub"]) + if path: + return path 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) +def _locate_job_m4b(job: Job) -> Optional[Path]: + return _find_job_file(job, [".m4b"]) - 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 _locate_job_audio(job: Job, preferred_suffixes: Optional[Iterable[str]] = None) -> Optional[Path]: + suffix_order: List[str] = [] + if preferred_suffixes: + suffix_order.extend(preferred_suffixes) + suffix_order.extend([".m4b", ".mp3", ".flac", ".opus", ".ogg", ".m4a", ".wav"]) + path = _find_job_file(job, suffix_order) + if path: + return path + files = _iter_job_result_paths(job) + return files[0] if files else None + + +def _job_download_flags(job: Job) -> Dict[str, bool]: + if job.status != JobStatus.COMPLETED: + return {"audio": False, "m4b": False, "epub3": False} + return { + "audio": _locate_job_audio(job) is not None, + "m4b": _locate_job_m4b(job) is not None, + "epub3": _locate_job_epub(job) is not None, + } def _build_narrator_roster( voice: str, voice_profile: Optional[str], @@ -2481,12 +2560,14 @@ def _render_jobs_panel() -> str: active_jobs = [job for job in jobs if job.status in active_statuses] active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at)) finished_jobs = [job for job in jobs if job.status not in active_statuses] + download_flags = {job.id: _job_download_flags(job) for job in jobs} return render_template( "partials/jobs.html", active_jobs=active_jobs, finished_jobs=finished_jobs[:5], total_finished=len(finished_jobs), JobStatus=JobStatus, + download_flags=download_flags, ) @@ -2524,6 +2605,7 @@ def job_detail(job_id: str) -> str: job=job, options=_template_options(), JobStatus=JobStatus, + downloads=_job_download_flags(job), ) @@ -2695,6 +2777,40 @@ def download_job(job_id: str) -> ResponseReturnValue: ) +@web_bp.get("/jobs//download/m4b") +def download_job_m4b(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_m4b(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=True, + download_name=audio_path.name, + ) + + +@web_bp.get("/jobs//download/epub3") +def download_job_epub3(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=True, + download_name=epub_path.name, + conditional=True, + ) + + @web_bp.get("/partials/jobs") def jobs_partial() -> str: return _render_jobs_panel() diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index f608fde..92a8d36 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -11,6 +11,26 @@ const initDashboard = () => { const dropzone = document.querySelector('[data-role="upload-dropzone"]'); const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]'); + const readerButtonRegistry = new WeakSet(); + + const bindReaderButtons = (root) => { + const context = root instanceof Element ? root : document; + const buttons = context.querySelectorAll('[data-role="open-reader"]'); + buttons.forEach((button) => { + if (!(button instanceof HTMLElement)) { + return; + } + if (readerButtonRegistry.has(button)) { + return; + } + button.addEventListener("click", (event) => { + event.preventDefault(); + openReaderModal(button); + }); + readerButtonRegistry.add(button); + }); + }; + const parseJSONScript = (id) => { const element = document.getElementById(id); if (!element) return null; @@ -131,9 +151,12 @@ const initDashboard = () => { }; const openReaderModal = (trigger) => { - if (!readerModal || !readerFrame) return; const url = trigger?.dataset.readerUrl || ""; if (!url) return; + if (!readerModal || !readerFrame) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } readerTrigger = trigger || null; const bookTitle = trigger?.dataset.bookTitle || ""; if (readerTitle) { @@ -215,13 +238,6 @@ const initDashboard = () => { if (readerClose) { event.preventDefault(); closeReaderModal(); - return; - } - - const readerTriggerBtn = target.closest('[data-role="open-reader"]'); - if (readerTriggerBtn) { - event.preventDefault(); - openReaderModal(readerTriggerBtn); } }); @@ -609,6 +625,17 @@ const initDashboard = () => { cancelPreviewRequest(); stopPreviewAudio(); }); + + bindReaderButtons(); + + document.addEventListener("htmx:afterSwap", (event) => { + const fragment = event?.detail?.target; + if (fragment instanceof Element) { + bindReaderButtons(fragment); + } else { + bindReaderButtons(); + } + }); }; if (document.readyState === "loading") { diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 7f97455..c45d874 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -245,6 +245,7 @@ document.addEventListener("DOMContentLoaded", () => { const initialStep = wizard.dataset.initialStep || "chapters"; const speakerModeSelect = form.querySelector("#speaker_mode"); const speakerIndicator = indicator ? indicator.querySelector('[data-step-key="speakers"]') : null; + const speakerPanel = wizard.querySelector('[data-speaker-panel="true"]'); const chapterActions = wizard.querySelector('[data-role="chapter-actions"]'); const continueButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="continue"]') : null; const finalizeButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="finalize"]') : null; @@ -357,9 +358,18 @@ document.addEventListener("DOMContentLoaded", () => { if (skipSpeakers) { speakerIndicator.hidden = true; speakerIndicator.setAttribute("aria-hidden", "true"); + speakerIndicator.style.display = "none"; } else { speakerIndicator.hidden = false; speakerIndicator.removeAttribute("aria-hidden"); + speakerIndicator.style.removeProperty("display"); + } + } + if (speakerPanel) { + if (skipSpeakers) { + speakerPanel.style.display = "none"; + } else { + speakerPanel.style.removeProperty("display"); } } if (continueButton) { diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index 1e1164e..a3ee9aa 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -39,9 +39,15 @@

Started: {{ job.started_at|datetimeformat }}

Finished: {{ job.finished_at|datetimeformat }}

Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}

- {% if job.result.audio_path %} -

Download audio

- {% endif %} + {% set flags = downloads or {} %} + {% if flags.get('m4b') %} +

Download M4B

+ {% elif flags.get('audio') %} +

Download audio

+ {% endif %} + {% if flags.get('epub3') %} +

Download EPUB 3

+ {% endif %} {% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
diff --git a/abogen/web/templates/partials/jobs.html b/abogen/web/templates/partials/jobs.html index 8f33e95..2cc857b 100644 --- a/abogen/web/templates/partials/jobs.html +++ b/abogen/web/templates/partials/jobs.html @@ -72,8 +72,16 @@ {% if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %} {% endif %} - {% if job.status == JobStatus.COMPLETED and job.result.audio_path %} - Download + {% set flags = download_flags.get(job.id, {}) %} + {% if job.status == JobStatus.COMPLETED %} + {% if flags.get('m4b') %} + Download M4B + {% elif flags.get('audio') %} + Download Audio + {% endif %} + {% if flags.get('epub3') %} + Download EPUB 3 + {% endif %} {% endif %} {% set reader_source = None %} {% if job.status == JobStatus.COMPLETED and job.result %} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index c23efdf..5efc5bd 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -20,7 +20,7 @@ 2 Chapters -