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 datetime import datetime
from html.parser import HTMLParser 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, Set, Tuple, cast
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from flask import ( from flask import (
@@ -340,42 +340,121 @@ def _read_epub_bytes(epub_path: Path, raw_href: str) -> bytes:
return archive.read(normalized) 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]: def _locate_job_epub(job: Job) -> Optional[Path]:
result = getattr(job, "result", None) path = _find_job_file(job, [".epub"])
if result is not None: if path:
primary = _coerce_path(getattr(result, "epub_path", None)) return path
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 return None
def _locate_job_audio(job: Job) -> Optional[Path]: def _locate_job_m4b(job: Job) -> Optional[Path]:
result = getattr(job, "result", None) return _find_job_file(job, [".m4b"])
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"}: def _locate_job_audio(job: Job, preferred_suffixes: Optional[Iterable[str]] = None) -> Optional[Path]:
return candidate suffix_order: List[str] = []
for candidate in candidates: if preferred_suffixes:
if candidate and candidate.exists(): suffix_order.extend(preferred_suffixes)
return candidate suffix_order.extend([".m4b", ".mp3", ".flac", ".opus", ".ogg", ".m4a", ".wav"])
return None 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( def _build_narrator_roster(
voice: str, voice: str,
voice_profile: Optional[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 = [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)) 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] 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( return render_template(
"partials/jobs.html", "partials/jobs.html",
active_jobs=active_jobs, active_jobs=active_jobs,
finished_jobs=finished_jobs[:5], finished_jobs=finished_jobs[:5],
total_finished=len(finished_jobs), total_finished=len(finished_jobs),
JobStatus=JobStatus, JobStatus=JobStatus,
download_flags=download_flags,
) )
@@ -2524,6 +2605,7 @@ def job_detail(job_id: str) -> str:
job=job, job=job,
options=_template_options(), options=_template_options(),
JobStatus=JobStatus, 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") @web_bp.get("/partials/jobs")
def jobs_partial() -> str: def jobs_partial() -> str:
return _render_jobs_panel() return _render_jobs_panel()
+35 -8
View File
@@ -11,6 +11,26 @@ const initDashboard = () => {
const dropzone = document.querySelector('[data-role="upload-dropzone"]'); const dropzone = document.querySelector('[data-role="upload-dropzone"]');
const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]'); 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 parseJSONScript = (id) => {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) return null; if (!element) return null;
@@ -131,9 +151,12 @@ const initDashboard = () => {
}; };
const openReaderModal = (trigger) => { const openReaderModal = (trigger) => {
if (!readerModal || !readerFrame) return;
const url = trigger?.dataset.readerUrl || ""; const url = trigger?.dataset.readerUrl || "";
if (!url) return; if (!url) return;
if (!readerModal || !readerFrame) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
readerTrigger = trigger || null; readerTrigger = trigger || null;
const bookTitle = trigger?.dataset.bookTitle || ""; const bookTitle = trigger?.dataset.bookTitle || "";
if (readerTitle) { if (readerTitle) {
@@ -215,13 +238,6 @@ const initDashboard = () => {
if (readerClose) { if (readerClose) {
event.preventDefault(); event.preventDefault();
closeReaderModal(); closeReaderModal();
return;
}
const readerTriggerBtn = target.closest('[data-role="open-reader"]');
if (readerTriggerBtn) {
event.preventDefault();
openReaderModal(readerTriggerBtn);
} }
}); });
@@ -609,6 +625,17 @@ const initDashboard = () => {
cancelPreviewRequest(); cancelPreviewRequest();
stopPreviewAudio(); stopPreviewAudio();
}); });
bindReaderButtons();
document.addEventListener("htmx:afterSwap", (event) => {
const fragment = event?.detail?.target;
if (fragment instanceof Element) {
bindReaderButtons(fragment);
} else {
bindReaderButtons();
}
});
}; };
if (document.readyState === "loading") { if (document.readyState === "loading") {
+10
View File
@@ -245,6 +245,7 @@ document.addEventListener("DOMContentLoaded", () => {
const initialStep = wizard.dataset.initialStep || "chapters"; const initialStep = wizard.dataset.initialStep || "chapters";
const speakerModeSelect = form.querySelector("#speaker_mode"); const speakerModeSelect = form.querySelector("#speaker_mode");
const speakerIndicator = indicator ? indicator.querySelector('[data-step-key="speakers"]') : null; 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 chapterActions = wizard.querySelector('[data-role="chapter-actions"]');
const continueButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="continue"]') : null; const continueButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="continue"]') : null;
const finalizeButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="finalize"]') : null; const finalizeButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="finalize"]') : null;
@@ -357,9 +358,18 @@ document.addEventListener("DOMContentLoaded", () => {
if (skipSpeakers) { if (skipSpeakers) {
speakerIndicator.hidden = true; speakerIndicator.hidden = true;
speakerIndicator.setAttribute("aria-hidden", "true"); speakerIndicator.setAttribute("aria-hidden", "true");
speakerIndicator.style.display = "none";
} else { } else {
speakerIndicator.hidden = false; speakerIndicator.hidden = false;
speakerIndicator.removeAttribute("aria-hidden"); speakerIndicator.removeAttribute("aria-hidden");
speakerIndicator.style.removeProperty("display");
}
}
if (speakerPanel) {
if (skipSpeakers) {
speakerPanel.style.display = "none";
} else {
speakerPanel.style.removeProperty("display");
} }
} }
if (continueButton) { if (continueButton) {
+9 -3
View File
@@ -39,9 +39,15 @@
<p>Started: {{ job.started_at|datetimeformat }}</p> <p>Started: {{ job.started_at|datetimeformat }}</p>
<p>Finished: {{ job.finished_at|datetimeformat }}</p> <p>Finished: {{ job.finished_at|datetimeformat }}</p>
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p> <p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
{% if job.result.audio_path %} {% set flags = downloads or {} %}
<p><a class="button" href="{{ url_for('web.download_job', job_id=job.id) }}">Download audio</a></p> {% if flags.get('m4b') %}
{% endif %} <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] %} {% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
<form action="{{ url_for('web.cancel_job', job_id=job.id) }}" method="post"> <form action="{{ url_for('web.cancel_job', job_id=job.id) }}" method="post">
<button type="submit" class="button button--ghost">Cancel job</button> <button type="submit" class="button button--ghost">Cancel job</button>
+10 -2
View File
@@ -72,8 +72,16 @@
{% if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %} {% 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> <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 %} {% endif %}
{% if job.status == JobStatus.COMPLETED and job.result.audio_path %} {% set flags = download_flags.get(job.id, {}) %}
<a class="button button--ghost" href="{{ url_for('web.download_job', job_id=job.id) }}">Download</a> {% 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 %} {% endif %}
{% set reader_source = None %} {% set reader_source = None %}
{% if job.status == JobStatus.COMPLETED and job.result %} {% 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__index">2</span>
<span class="step-indicator__label">Chapters</span> <span class="step-indicator__label">Chapters</span>
</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__index">3</span>
<span class="step-indicator__label">Speakers</span> <span class="step-indicator__label">Speakers</span>
</span> </span>
@@ -170,7 +170,7 @@
</div> </div>
</section> </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"> <header class="prepare-step__header">
<h2>Step 3 · Select speakers</h2> <h2>Step 3 · Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalize your roster.</p> <p class="hint">Assign voices, audition samples, and finalize your roster.</p>