mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Enhance entity and job management with new routes and template updates
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 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.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__)
|
||||
|
||||
@@ -96,6 +100,29 @@ def search_candidates(pending_id: str) -> ResponseReturnValue:
|
||||
results = search_manual_override_candidates(pending, query, limit=limit_value)
|
||||
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("/")
|
||||
def entities_page() -> str:
|
||||
settings = load_settings()
|
||||
|
||||
@@ -275,6 +275,13 @@ def stream_logs(job_id: str) -> ResponseReturnValue:
|
||||
|
||||
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")
|
||||
def queue_page() -> str:
|
||||
return render_template(
|
||||
|
||||
@@ -14,12 +14,21 @@ from abogen.web.routes.utils.voice import template_options
|
||||
|
||||
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("/")
|
||||
def settings_page() -> str:
|
||||
return render_template(
|
||||
"settings.html",
|
||||
settings=load_settings(),
|
||||
options=template_options(),
|
||||
normalization_samples=_NORMALIZATION_SAMPLES,
|
||||
)
|
||||
|
||||
@settings_bp.post("/update")
|
||||
|
||||
@@ -23,7 +23,7 @@ voices_bp = Blueprint("voices", __name__)
|
||||
|
||||
@voices_bp.get("/")
|
||||
def voice_profiles() -> ResponseReturnValue:
|
||||
return render_template("voices.html")
|
||||
return render_template("voices.html", options=template_options())
|
||||
|
||||
@voices_bp.post("/test")
|
||||
def test_voice() -> ResponseReturnValue:
|
||||
@@ -76,3 +76,65 @@ def delete_speaker_config() -> ResponseReturnValue:
|
||||
|
||||
delete_config(name)
|
||||
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"))
|
||||
|
||||
@@ -49,11 +49,11 @@
|
||||
</label>
|
||||
<div class="entity-filter__actions">
|
||||
<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>
|
||||
</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 class="card card--panel">
|
||||
@@ -85,7 +85,7 @@
|
||||
id="override-create-form"
|
||||
class="form-section entity-create"
|
||||
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>
|
||||
<p class="entity-create__description">Create pronunciations or assign default voices without preparing a job.</p>
|
||||
@@ -243,7 +243,7 @@
|
||||
id="{{ form_id }}"
|
||||
class="overrides-table__form"
|
||||
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="token" value="{{ override.token }}">
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
<p class="upload-card__filename" data-role="upload-dropzone-filename" hidden></p>
|
||||
<button type="button" class="button" data-role="open-upload-modal">Open upload & settings</button>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
</section>
|
||||
{% 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" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,30 +62,30 @@
|
||||
<li class="job-card">
|
||||
<div class="job-card__header">
|
||||
<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>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
</div>
|
||||
<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] %}
|
||||
<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 %}
|
||||
{% 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>
|
||||
<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') %}
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
{% if audiobookshelf_manual_available %}
|
||||
<button type="button"
|
||||
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-swap="innerHTML">
|
||||
Send to Audiobookshelf
|
||||
@@ -106,14 +106,14 @@
|
||||
<button type="button"
|
||||
class="icon-button"
|
||||
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 }}"
|
||||
title="Open reader"
|
||||
aria-label="Open EPUB reader for {{ job_display_title }}">
|
||||
📖
|
||||
</button>
|
||||
{% 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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{% 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 %}
|
||||
<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"
|
||||
id="prepare-form"
|
||||
data-role="prepare-form"
|
||||
data-wizard-form="true"
|
||||
data-step="entities"
|
||||
data-pending-id="{{ pending.id }}"
|
||||
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
data-entities-url="{{ url_for('api.api_pending_entities', pending_id=pending.id) }}"
|
||||
data-manual-list-url="{{ url_for('api.api_list_manual_overrides', pending_id=pending.id) }}"
|
||||
data-manual-upsert-url="{{ url_for('api.api_upsert_manual_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-search-url="{{ url_for('api.api_search_manual_override_candidates', pending_id=pending.id) }}"
|
||||
data-analyze-url="{{ url_for('entities.analyze_entities', pending_id=pending.id) }}"
|
||||
data-entities-url="{{ url_for('entities.get_entities', pending_id=pending.id) }}"
|
||||
data-manual-list-url="{{ url_for('entities.list_manual_overrides', pending_id=pending.id) }}"
|
||||
data-manual-upsert-url="{{ url_for('entities.upsert_override', pending_id=pending.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('entities.search_candidates', pending_id=pending.id) }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-base-voice="{{ pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
@@ -128,7 +128,7 @@
|
||||
name="apply_speaker_config"
|
||||
data-role="submit-speaker-analysis"
|
||||
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"
|
||||
formnovalidate
|
||||
{% if not options.speaker_configs %}disabled{% endif %}>
|
||||
|
||||
@@ -94,15 +94,15 @@
|
||||
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</footer>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
</div>
|
||||
<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__overlay" data-role="voice-modal-close" tabindex="-1"></div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div id="jobs-panel"
|
||||
hx-get="{{ url_for('web.jobs_partial') }}"
|
||||
hx-get="{{ url_for('jobs.jobs_partial') }}"
|
||||
hx-trigger="load, every 3s"
|
||||
hx-target="#jobs-panel"
|
||||
hx-swap="innerHTML">
|
||||
|
||||
@@ -68,14 +68,14 @@
|
||||
<aside class="speaker-configs__sidebar">
|
||||
<div class="speaker-configs__sidebar-header">
|
||||
<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>
|
||||
<ul class="speaker-configs__list">
|
||||
{% for config in configs %}
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</li>
|
||||
@@ -135,7 +135,9 @@
|
||||
|
||||
<div class="speaker-configs__actions">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user