mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance job handling with download options for M4B, EPUB 3, and improved UI for speaker selection
This commit is contained in:
+148
-32
@@ -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()
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user