feat: Enhance job handling with download options for M4B, EPUB 3, and improved UI for speaker selection

This commit is contained in:
JB
2025-10-11 06:04:44 -07:00
parent e15d2b12a3
commit 3e7bbd648c
6 changed files with 214 additions and 47 deletions
+148 -32
View File
@@ -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/<job_id>/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/<job_id>/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()
+35 -8
View File
@@ -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") {
+10
View File
@@ -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) {
+7 -1
View File
@@ -39,8 +39,14 @@
<p>Started: {{ job.started_at|datetimeformat }}</p>
<p>Finished: {{ job.finished_at|datetimeformat }}</p>
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
{% if job.result.audio_path %}
{% set flags = downloads or {} %}
{% if flags.get('m4b') %}
<p><a class="button" href="{{ url_for('web.download_job_m4b', job_id=job.id) }}">Download M4B</a></p>
{% elif flags.get('audio') %}
<p><a class="button" href="{{ url_for('web.download_job', job_id=job.id) }}">Download audio</a></p>
{% endif %}
{% if flags.get('epub3') %}
<p><a class="button button--ghost" href="{{ url_for('web.download_job_epub3', job_id=job.id) }}">Download EPUB 3</a></p>
{% endif %}
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
<form action="{{ url_for('web.cancel_job', job_id=job.id) }}" method="post">
+10 -2
View File
@@ -72,8 +72,16 @@
{% if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.retry_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Retry</button>
{% endif %}
{% 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>
{% set flags = download_flags.get(job.id, {}) %}
{% if job.status == JobStatus.COMPLETED %}
{% if flags.get('m4b') %}
<a class="button button--ghost" href="{{ url_for('web.download_job_m4b', job_id=job.id) }}">Download M4B</a>
{% elif flags.get('audio') %}
<a class="button button--ghost" href="{{ url_for('web.download_job', job_id=job.id) }}">Download Audio</a>
{% endif %}
{% if flags.get('epub3') %}
<a class="button button--ghost" href="{{ url_for('web.download_job_epub3', job_id=job.id) }}">Download EPUB 3</a>
{% endif %}
{% endif %}
{% set reader_source = None %}
{% if job.status == JobStatus.COMPLETED and job.result %}
+2 -2
View File
@@ -20,7 +20,7 @@
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span>
</span>
<span class="step-indicator__item{% if is_speakers %} is-active{% endif %}" data-role="wizard-step" data-step-key="speakers" {% if not is_multi_speaker %}hidden aria-hidden="true"{% endif %}>
<span class="step-indicator__item{% if is_speakers %} is-active{% endif %}" data-role="wizard-step" data-step-key="speakers" {% if not is_multi_speaker %}hidden aria-hidden="true" style="display:none;"{% endif %}>
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span>
</span>
@@ -170,7 +170,7 @@
</div>
</section>
<section class="prepare-step" data-step-panel="speakers" data-speaker-panel="true" hidden>
<section class="prepare-step" data-step-panel="speakers" data-speaker-panel="true" {% if not is_multi_speaker %}style="display:none;"{% endif %} hidden>
<header class="prepare-step__header">
<h2>Step 3 · Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalize your roster.</p>