mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Implement job management features with improved UI for active and finished jobs
This commit is contained in:
+26
-1
@@ -763,6 +763,19 @@ def enqueue_job() -> Response:
|
||||
return redirect(url_for("web.job_detail", job_id=job.id))
|
||||
|
||||
|
||||
def _render_jobs_panel() -> str:
|
||||
jobs = _service().list_jobs()
|
||||
active_jobs = [job for job in jobs if job.status in {JobStatus.PENDING, JobStatus.RUNNING}]
|
||||
finished_jobs = [job for job in jobs if job.status not in {JobStatus.PENDING, JobStatus.RUNNING}]
|
||||
return render_template(
|
||||
"partials/jobs.html",
|
||||
active_jobs=active_jobs,
|
||||
finished_jobs=finished_jobs[:5],
|
||||
total_finished=len(finished_jobs),
|
||||
JobStatus=JobStatus,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.get("/jobs/<job_id>")
|
||||
def job_detail(job_id: str) -> str:
|
||||
job = _service().get_job(job_id)
|
||||
@@ -778,15 +791,27 @@ def job_detail(job_id: str) -> str:
|
||||
@web_bp.post("/jobs/<job_id>/cancel")
|
||||
def cancel_job(job_id: str) -> Response:
|
||||
_service().cancel(job_id)
|
||||
if request.headers.get("HX-Request"):
|
||||
return _render_jobs_panel()
|
||||
return redirect(url_for("web.job_detail", job_id=job_id))
|
||||
|
||||
|
||||
@web_bp.post("/jobs/<job_id>/delete")
|
||||
def delete_job(job_id: str) -> Response:
|
||||
_service().delete(job_id)
|
||||
if request.headers.get("HX-Request"):
|
||||
return _render_jobs_panel()
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
|
||||
@web_bp.post("/jobs/clear-finished")
|
||||
def clear_finished_jobs() -> Response:
|
||||
_service().clear_finished()
|
||||
if request.headers.get("HX-Request"):
|
||||
return _render_jobs_panel()
|
||||
return redirect(url_for("web.queue_page"))
|
||||
|
||||
|
||||
@web_bp.get("/jobs/<job_id>/download")
|
||||
def download_job(job_id: str) -> Response:
|
||||
job = _service().get_job(job_id)
|
||||
@@ -812,7 +837,7 @@ def download_job(job_id: str) -> Response:
|
||||
|
||||
@web_bp.get("/partials/jobs")
|
||||
def jobs_partial() -> str:
|
||||
return render_template("partials/jobs.html", jobs=_service().list_jobs())
|
||||
return _render_jobs_panel()
|
||||
|
||||
|
||||
@web_bp.get("/partials/jobs/<job_id>/logs")
|
||||
|
||||
@@ -222,6 +222,28 @@ class ConversionService:
|
||||
self._update_queue_positions_locked()
|
||||
return True
|
||||
|
||||
def clear_finished(self, *, statuses: Optional[Iterable[JobStatus]] = None) -> int:
|
||||
finished_statuses = set(statuses or {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED})
|
||||
removed = 0
|
||||
with self._lock:
|
||||
# Remove any queued entries first to avoid stale references
|
||||
filtered_queue: List[str] = []
|
||||
for job_id in self._queue:
|
||||
job = self._jobs.get(job_id)
|
||||
if job and job.status in finished_statuses:
|
||||
continue
|
||||
filtered_queue.append(job_id)
|
||||
self._queue = filtered_queue
|
||||
|
||||
for job_id, job in list(self._jobs.items()):
|
||||
if job.status in finished_statuses:
|
||||
self._jobs.pop(job_id)
|
||||
removed += 1
|
||||
|
||||
if removed:
|
||||
self._update_queue_positions_locked()
|
||||
return removed
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._wake_event.set()
|
||||
|
||||
@@ -144,6 +144,9 @@ body {
|
||||
|
||||
.form-grid > .grid {
|
||||
align-items: start;
|
||||
max-width: 460px;
|
||||
width: min(100%, 460px);
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -367,6 +370,7 @@ body {
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
overflow: hidden;
|
||||
--progress: 0%;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
@@ -374,6 +378,7 @@ body {
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||
border-radius: inherit;
|
||||
transition: width 0.35s ease;
|
||||
width: var(--progress);
|
||||
}
|
||||
|
||||
.list {
|
||||
@@ -395,6 +400,142 @@ body {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.queue-section:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.queue-section__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.queue-section__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.job-cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 20px;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.job-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.job-card__meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.job-card__progress {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.job-card__progress small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.job-card__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.job-card__footer .button--ghost {
|
||||
padding: 0.6rem 0.95rem;
|
||||
}
|
||||
|
||||
.job-cards--compact .job-card {
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.job-cards--compact .job-card__meta {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.job-card--info {
|
||||
border-style: dashed;
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
background: rgba(15, 23, 42, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.job-card--info .job-card__meta {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field--file input[type="file"] {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
padding: 0.85rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: border 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.field--file input[type="file"]:hover,
|
||||
.field--file input[type="file"]:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
|
||||
.field--file input[type="file"]::file-selector-button,
|
||||
.field--file input[type="file"]::-webkit-file-upload-button {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
padding: 0.55rem 1.05rem;
|
||||
margin-right: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.field--file input[type="file"]:hover::file-selector-button,
|
||||
.field--file input[type="file"]:focus::file-selector-button,
|
||||
.field--file input[type="file"]:hover::-webkit-file-upload-button,
|
||||
.field--file input[type="file"]:focus::-webkit-file-upload-button {
|
||||
background: rgba(56, 189, 248, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="card__title">Create a New Audiobook</h1>
|
||||
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two form-grid">
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<div class="field field--file">
|
||||
<label for="source_file">Source File</label>
|
||||
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,74 @@
|
||||
<div class="card__title">Queue</div>
|
||||
{% if jobs %}
|
||||
<table class="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<section class="queue-section">
|
||||
<header class="queue-section__header">
|
||||
<h3>Active jobs</h3>
|
||||
</header>
|
||||
{% if active_jobs %}
|
||||
<ul class="job-cards">
|
||||
{% for job in active_jobs %}
|
||||
<li class="job-card">
|
||||
<div class="job-card__header">
|
||||
<div>
|
||||
<strong>{{ job.original_filename }}</strong>
|
||||
<div class="tag">
|
||||
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
|
||||
<div class="job-card__meta">{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<progress value="{{ job.processed_characters or 0 }}" max="{{ job.total_characters or 1 }}" class="progress"></progress>
|
||||
</div>
|
||||
<div class="job-card__progress">
|
||||
<div class="progress-bar" style="--progress: {{ ((job.progress or 0) * 100)|round(1) }}%">
|
||||
<div class="progress-bar__fill"></div>
|
||||
</div>
|
||||
<small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
|
||||
</td>
|
||||
<td><small>{{ job.created_at|datetimeformat }}</small></td>
|
||||
<td><a class="btn" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a></td>
|
||||
</tr>
|
||||
</div>
|
||||
<div class="job-card__footer">
|
||||
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a>
|
||||
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING] %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No jobs yet. Drop a file or paste text to get started.</p>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="queue-empty">No active jobs. Drop a file or paste text to get started.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="queue-section">
|
||||
<header class="queue-section__header">
|
||||
<h3>Recent results</h3>
|
||||
{% if total_finished > 0 %}
|
||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.clear_finished_jobs') }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove completed jobs from this list?">Clear finished</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
{% if finished_jobs %}
|
||||
<ul class="job-cards job-cards--compact">
|
||||
{% for job in finished_jobs %}
|
||||
<li class="job-card">
|
||||
<div class="job-card__header">
|
||||
<div>
|
||||
<strong>{{ job.original_filename }}</strong>
|
||||
<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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if total_finished > finished_jobs|length %}
|
||||
<li class="job-card job-card--info">
|
||||
<div class="job-card__meta">{{ total_finished - finished_jobs|length }} more finished job{{ 's' if (total_finished - finished_jobs|length) != 1 else '' }} hidden. Clear finished to remove them.</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="queue-empty">Completed jobs will appear here.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user