feat: Enhance entity and job management with new routes and template updates

This commit is contained in:
JB
2025-11-28 16:00:16 -08:00
parent 124e5b33db
commit c00cbaef69
12 changed files with 145 additions and 35 deletions
+28 -1
View File
@@ -1,5 +1,5 @@
from typing import Mapping from typing import Mapping
from flask import Blueprint, request, jsonify, abort, render_template from flask import Blueprint, request, jsonify, abort, render_template, redirect, url_for
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
from abogen.web.routes.utils.service import require_pending_job, get_service from abogen.web.routes.utils.service import require_pending_job, get_service
@@ -12,6 +12,10 @@ from abogen.web.routes.utils.entity import (
) )
from abogen.web.routes.utils.settings import coerce_int, load_settings from abogen.web.routes.utils.settings import coerce_int, load_settings
from abogen.web.routes.utils.voice import template_options from abogen.web.routes.utils.voice import template_options
from abogen.pronunciation_store import (
delete_override as delete_pronunciation_override,
save_override as save_pronunciation_override,
)
entities_bp = Blueprint("entities", __name__) entities_bp = Blueprint("entities", __name__)
@@ -96,6 +100,29 @@ def search_candidates(pending_id: str) -> ResponseReturnValue:
results = search_manual_override_candidates(pending, query, limit=limit_value) results = search_manual_override_candidates(pending, query, limit=limit_value)
return jsonify({"query": query, "limit": limit_value, "results": results}) return jsonify({"query": query, "limit": limit_value, "results": results})
@entities_bp.post("/overrides")
def upsert_global_override() -> ResponseReturnValue:
payload = request.form
action = payload.get("action", "save")
lang = payload.get("lang", "en")
token = payload.get("token", "").strip()
if action == "delete":
if token:
delete_pronunciation_override(token=token, language=lang)
else:
pronunciation = payload.get("pronunciation", "").strip()
voice = payload.get("voice", "").strip()
if token:
save_pronunciation_override(
token=token,
pronunciation=pronunciation,
voice=voice or None,
language=lang
)
return redirect(url_for("entities.entities_page", lang=lang))
@entities_bp.get("/") @entities_bp.get("/")
def entities_page() -> str: def entities_page() -> str:
settings = load_settings() settings = load_settings()
+7
View File
@@ -275,6 +275,13 @@ def stream_logs(job_id: str) -> ResponseReturnValue:
return Response(generate(), mimetype="text/event-stream") return Response(generate(), mimetype="text/event-stream")
@jobs_bp.get("/<job_id>/reader")
def job_reader(job_id: str) -> ResponseReturnValue:
job = get_service().get_job(job_id)
if not job:
abort(404)
return render_template("reader_embed.html", job=job)
@jobs_bp.get("/queue") @jobs_bp.get("/queue")
def queue_page() -> str: def queue_page() -> str:
return render_template( return render_template(
+9
View File
@@ -14,12 +14,21 @@ from abogen.web.routes.utils.voice import template_options
settings_bp = Blueprint("settings", __name__) settings_bp = Blueprint("settings", __name__)
_NORMALIZATION_SAMPLES = {
"apostrophes": "It's a beautiful day, isn't it? 'Yes,' she said, 'it is.'",
"currency": "The price is $10.50, but it was £8.00 yesterday.",
"dates": "On 2023-01-01, we celebrated the new year.",
"numbers": "There are 123 apples and 456 oranges.",
"abbreviations": "Dr. Smith lives on Elm St. near the U.S. border.",
}
@settings_bp.get("/") @settings_bp.get("/")
def settings_page() -> str: def settings_page() -> str:
return render_template( return render_template(
"settings.html", "settings.html",
settings=load_settings(), settings=load_settings(),
options=template_options(), options=template_options(),
normalization_samples=_NORMALIZATION_SAMPLES,
) )
@settings_bp.post("/update") @settings_bp.post("/update")
+63 -1
View File
@@ -23,7 +23,7 @@ voices_bp = Blueprint("voices", __name__)
@voices_bp.get("/") @voices_bp.get("/")
def voice_profiles() -> ResponseReturnValue: def voice_profiles() -> ResponseReturnValue:
return render_template("voices.html") return render_template("voices.html", options=template_options())
@voices_bp.post("/test") @voices_bp.post("/test")
def test_voice() -> ResponseReturnValue: def test_voice() -> ResponseReturnValue:
@@ -76,3 +76,65 @@ def delete_speaker_config() -> ResponseReturnValue:
delete_config(name) delete_config(name)
return jsonify({"status": "deleted", "configs": list_configs()}) return jsonify({"status": "deleted", "configs": list_configs()})
@voices_bp.route("/presets", methods=["GET", "POST"])
def speaker_configs_page() -> ResponseReturnValue:
configs = load_configs()
editing_name = request.args.get("config")
message = None
error = None
if request.method == "POST":
try:
name = request.form.get("config_name", "").strip()
if not name:
raise ValueError("Preset name is required")
language = request.form.get("config_language", "en")
speakers = []
row_keys = request.form.getlist("speaker_rows")
for key in row_keys:
s_id = request.form.get(f"speaker-{key}-id", key)
label = request.form.get(f"speaker-{key}-label", "")
gender = request.form.get(f"speaker-{key}-gender", "unknown")
voice = request.form.get(f"speaker-{key}-voice", "")
if label:
speakers.append({
"id": s_id,
"label": label,
"gender": gender,
"voice": voice or None
})
config = {
"name": name,
"language": language,
"speakers": speakers,
"version": 1
}
configs[name] = config
save_configs(configs)
message = f"Preset '{name}' saved."
editing_name = name
except Exception as e:
error = str(e)
editing = configs.get(editing_name, {}) if editing_name else {}
return render_template(
"speakers.html",
options=template_options(),
configs=configs.values(),
editing_name=editing_name,
editing=editing,
message=message,
error=error
)
@voices_bp.post("/presets/<name>/delete")
def delete_speaker_config_named(name: str) -> ResponseReturnValue:
delete_config(name)
return redirect(url_for("voices.speaker_configs_page"))
+4 -4
View File
@@ -49,11 +49,11 @@
</label> </label>
<div class="entity-filter__actions"> <div class="entity-filter__actions">
<button type="submit" class="button">Apply filters</button> <button type="submit" class="button">Apply filters</button>
<a href="{{ url_for('web.entities_page') }}" class="button button--ghost">Reset</a> <a href="{{ url_for('entities.entities_page') }}" class="button button--ghost">Reset</a>
</div> </div>
</div> </div>
</form> </form>
<p class="hint">Looking for saved speaker rosters instead? <a href="{{ url_for('web.speaker_configs_page') }}">Manage speaker presets</a>.</p> <p class="hint">Looking for saved speaker rosters instead? <a href="{{ url_for('voices.speaker_configs_page') }}">Manage speaker presets</a>.</p>
</section> </section>
<section class="card card--panel"> <section class="card card--panel">
@@ -85,7 +85,7 @@
id="override-create-form" id="override-create-form"
class="form-section entity-create" class="form-section entity-create"
method="post" method="post"
action="{{ url_for('web.entities_override_update') }}" action="{{ url_for('entities.upsert_global_override') }}"
> >
<h3 class="form-section__title">Add manual override</h3> <h3 class="form-section__title">Add manual override</h3>
<p class="entity-create__description">Create pronunciations or assign default voices without preparing a job.</p> <p class="entity-create__description">Create pronunciations or assign default voices without preparing a job.</p>
@@ -243,7 +243,7 @@
id="{{ form_id }}" id="{{ form_id }}"
class="overrides-table__form" class="overrides-table__form"
method="post" method="post"
action="{{ url_for('web.entities_override_update') }}" action="{{ url_for('entities.upsert_global_override') }}"
> >
<input type="hidden" name="lang" value="{{ language }}"> <input type="hidden" name="lang" value="{{ language }}">
<input type="hidden" name="token" value="{{ override.token }}"> <input type="hidden" name="token" value="{{ override.token }}">
+4 -1
View File
@@ -14,7 +14,10 @@
<p class="upload-card__filename" data-role="upload-dropzone-filename" hidden></p> <p class="upload-card__filename" data-role="upload-dropzone-filename" hidden></p>
<button type="button" class="button" data-role="open-upload-modal">Open upload &amp; settings</button> <button type="button" class="button" data-role="open-upload-modal">Open upload &amp; settings</button>
{% if opds_available %} {% if opds_available %}
<a class="button button--ghost" href="{{ url_for('web.find_books_page') }}">Browse Calibre library</a> <div class="wizard-card__body">
<p>Upload an EPUB or text file to begin.</p>
<a class="button button--ghost" href="{{ url_for('books.find_books_page') }}">Browse Calibre library</a>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+1 -1
View File
@@ -149,7 +149,7 @@
</section> </section>
{% endif %} {% endif %}
<section class="card" id="logs" hx-get="{{ url_for('web.job_logs_partial', job_id=job.id) }}" hx-trigger="load, every 2s" hx-target="#logs" hx-swap="innerHTML"> <section class="card" id="logs" hx-get="{{ url_for('jobs.job_logs', job_id=job.id) }}" hx-trigger="load, every 2s" hx-target="#logs" hx-swap="innerHTML">
{% include "partials/logs.html" %} {% include "partials/logs.html" %}
</section> </section>
{% endblock %} {% endblock %}
+9 -9
View File
@@ -62,30 +62,30 @@
<li class="job-card"> <li class="job-card">
<div class="job-card__header"> <div class="job-card__header">
<div> <div>
<a class="job-card__title" href="{{ url_for('web.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a> <a class="job-card__title" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">{{ job.original_filename }}</a>
<div class="job-card__meta">Finished {{ job.finished_at|datetimeformat }}</div> <div class="job-card__meta">Finished {{ job.finished_at|datetimeformat }}</div>
</div> </div>
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span> <span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
</div> </div>
<div class="job-card__footer"> <div class="job-card__footer">
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a> <a class="button button--ghost" href="{{ url_for('jobs.job_detail', job_id=job.id) }}">Inspect</a>
{% 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('jobs.retry_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML">Retry</button>
{% endif %} {% endif %}
{% set flags = download_flags.get(job.id, {}) %} {% set flags = download_flags.get(job.id, {}) %}
{% if job.status == JobStatus.COMPLETED %} {% if job.status == JobStatus.COMPLETED %}
{% if flags.get('m4b') %} {% if flags.get('m4b') %}
<a class="button button--ghost" href="{{ url_for('web.download_job_m4b', job_id=job.id) }}">Download M4B</a> <a class="button button--ghost" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download M4B</a>
{% elif flags.get('audio') %} {% elif flags.get('audio') %}
<a class="button button--ghost" href="{{ url_for('web.download_job', job_id=job.id) }}">Download Audio</a> <a class="button button--ghost" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download Audio</a>
{% endif %} {% endif %}
{% if flags.get('epub3') %} {% if flags.get('epub3') %}
<a class="button button--ghost" href="{{ url_for('web.download_job_epub3', job_id=job.id) }}">Download EPUB 3</a> <a class="button button--ghost" href="{{ url_for('jobs.job_epub', job_id=job.id) }}">Download EPUB 3</a>
{% endif %} {% endif %}
{% if audiobookshelf_manual_available %} {% if audiobookshelf_manual_available %}
<button type="button" <button type="button"
class="button button--ghost" class="button button--ghost"
hx-post="{{ url_for('web.send_job_to_audiobookshelf', job_id=job.id) }}" hx-post="{{ url_for('jobs.send_job_to_audiobookshelf', job_id=job.id) }}"
hx-target="#jobs-panel" hx-target="#jobs-panel"
hx-swap="innerHTML"> hx-swap="innerHTML">
Send to Audiobookshelf Send to Audiobookshelf
@@ -106,14 +106,14 @@
<button type="button" <button type="button"
class="icon-button" class="icon-button"
data-role="open-reader" data-role="open-reader"
data-reader-url="{{ url_for('web.job_reader', job_id=job.id) }}" data-reader-url="{{ url_for('jobs.job_reader', job_id=job.id) }}"
data-book-title="{{ job_display_title }}" data-book-title="{{ job_display_title }}"
title="Open reader" title="Open reader"
aria-label="Open EPUB reader for {{ job_display_title }}"> aria-label="Open EPUB reader for {{ job_display_title }}">
📖 📖
</button> </button>
{% endif %} {% 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('jobs.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>
{% endfor %} {% endfor %}
@@ -1,19 +1,19 @@
{% set embed_scripts = embed_scripts if embed_scripts is defined else True %} {% set embed_scripts = embed_scripts if embed_scripts is defined else True %}
{% set recognition_enabled = settings.enable_entity_recognition if settings is defined and settings.enable_entity_recognition is not none else True %} {% set recognition_enabled = settings.enable_entity_recognition if settings is defined and settings.enable_entity_recognition is not none else True %}
<form method="post" <form method="post"
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" action="{{ url_for('main.wizard_finish', pending_id=pending.id) }}"
class="prepare-form" class="prepare-form"
id="prepare-form" id="prepare-form"
data-role="prepare-form" data-role="prepare-form"
data-wizard-form="true" data-wizard-form="true"
data-step="entities" data-step="entities"
data-pending-id="{{ pending.id }}" data-pending-id="{{ pending.id }}"
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}" data-analyze-url="{{ url_for('entities.analyze_entities', pending_id=pending.id) }}"
data-entities-url="{{ url_for('api.api_pending_entities', pending_id=pending.id) }}" data-entities-url="{{ url_for('entities.get_entities', pending_id=pending.id) }}"
data-manual-list-url="{{ url_for('api.api_list_manual_overrides', pending_id=pending.id) }}" data-manual-list-url="{{ url_for('entities.list_manual_overrides', pending_id=pending.id) }}"
data-manual-upsert-url="{{ url_for('api.api_upsert_manual_override', pending_id=pending.id) }}" data-manual-upsert-url="{{ url_for('entities.upsert_override', pending_id=pending.id) }}"
data-manual-delete-url-template="{{ url_for('api.api_delete_manual_override', pending_id=pending.id, override_id='__OVERRIDE_ID__') }}" data-manual-delete-url-template="{{ url_for('entities.delete_override', pending_id=pending.id, override_id='__OVERRIDE_ID__') }}"
data-manual-search-url="{{ url_for('api.api_search_manual_override_candidates', pending_id=pending.id) }}" data-manual-search-url="{{ url_for('entities.search_candidates', pending_id=pending.id) }}"
data-language="{{ pending.language }}" data-language="{{ pending.language }}"
data-base-voice="{{ pending.voice }}" data-base-voice="{{ pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}" data-speed="{{ '%.2f'|format(pending.speed) }}"
@@ -128,7 +128,7 @@
name="apply_speaker_config" name="apply_speaker_config"
data-role="submit-speaker-analysis" data-role="submit-speaker-analysis"
value="1" value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}" formaction="{{ url_for('entities.analyze_entities', pending_id=pending.id) }}"
formmethod="post" formmethod="post"
formnovalidate formnovalidate
{% if not options.speaker_configs %}disabled{% endif %}> {% if not options.speaker_configs %}disabled{% endif %}>
+4 -4
View File
@@ -94,15 +94,15 @@
<footer class="modal__footer wizard-card__footer"> <footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions"> <div class="wizard-card__footer-actions">
<a class="button button--ghost" href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">Previous</a> <a class="button button--ghost" href="{{ url_for('main.wizard_step', pending_id=pending.id, step='chapters') }}">Previous</a>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button> <button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div> </div>
<div class="wizard-card__footer-actions"> <div class="wizard-card__footer-actions">
<button type="submit" class="button">Queue conversion</button> <button type="submit" class="button" form="prepare-form">Next: Review Entities</button>
</div> </div>
</footer> </footer>
</form> </div>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form> <form method="post" action="{{ url_for('main.wizard_cancel', pending_id=pending.id) }}" id="cancel-form"></form>
<div class="modal" data-role="voice-modal" hidden> <div class="modal" data-role="voice-modal" hidden>
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div> <div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
+1 -1
View File
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<section class="card"> <section class="card">
<div id="jobs-panel" <div id="jobs-panel"
hx-get="{{ url_for('web.jobs_partial') }}" hx-get="{{ url_for('jobs.jobs_partial') }}"
hx-trigger="load, every 3s" hx-trigger="load, every 3s"
hx-target="#jobs-panel" hx-target="#jobs-panel"
hx-swap="innerHTML"> hx-swap="innerHTML">
+6 -4
View File
@@ -68,14 +68,14 @@
<aside class="speaker-configs__sidebar"> <aside class="speaker-configs__sidebar">
<div class="speaker-configs__sidebar-header"> <div class="speaker-configs__sidebar-header">
<h2>Saved presets</h2> <h2>Saved presets</h2>
<a class="button button--ghost button--small" href="{{ url_for('web.speaker_configs_page') }}">New preset</a> <a class="button button--ghost button--small" href="{{ url_for('voices.speaker_configs_page') }}">New preset</a>
</div> </div>
<ul class="speaker-configs__list"> <ul class="speaker-configs__list">
{% for config in configs %} {% for config in configs %}
<li class="speaker-configs__entry {% if editing_name == config.name %}is-active{% endif %}"> <li class="speaker-configs__entry {% if editing_name == config.name %}is-active{% endif %}">
<a href="{{ url_for('web.speaker_configs_page', config=config.name) }}">{{ config.name }}</a> <a href="{{ url_for('voices.speaker_configs_page', config=config.name) }}">{{ config.name }}</a>
<span class="speaker-configs__meta">{{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</span> <span class="speaker-configs__meta">{{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</span>
<form method="post" action="{{ url_for('web.delete_speaker_config_route', name=config.name) }}" class="speaker-configs__delete" onsubmit="return confirm('Delete preset {{ config.name }}?');"> <form method="post" action="{{ url_for('voices.delete_speaker_config_named', name=config.name) }}" class="speaker-configs__delete" onsubmit="return confirm('Delete preset {{ config.name }}?');">
<button type="submit" class="link-button">Delete</button> <button type="submit" class="link-button">Delete</button>
</form> </form>
</li> </li>
@@ -135,7 +135,9 @@
<div class="speaker-configs__actions"> <div class="speaker-configs__actions">
<button type="submit" class="button">Save configuration</button> <button type="submit" class="button">Save configuration</button>
<a class="button button--ghost" href="{{ url_for('web.index') }}">Back to dashboard</a> <div class="speaker-configs__actions-right">
<a class="button button--ghost" href="{{ url_for('main.index') }}">Back to dashboard</a>
</div>
</div> </div>
</form> </form>
</div> </div>