Auto-queue transcription on upload and clarify pending status.

Batch uploads were only storing files as pending without dispatching Whisper jobs; queue them immediately, add a bulk pending action, and show human-readable status labels.
This commit is contained in:
ben
2026-08-12 16:26:36 +02:00
parent 3498861184
commit 1dcdfc0ed0
12 changed files with 525 additions and 95 deletions
+180 -11
View File
@@ -4,32 +4,82 @@
@section('content')
<div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight">Upload recording</h1>
<p class="mt-1 text-sm text-stone-600">Upload audio from your pocket recorder. Embedded metadata is extracted when available.</p>
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
</p>
</div>
<form
method="POST"
action="{{ route('recordings.store') }}"
enctype="multipart/form-data"
class="max-w-xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
x-data="uploadDropzone()"
@submit="ensureFilesSelected($event)"
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
>
@csrf
<div>
<label for="audio" class="block text-sm font-medium text-stone-700">Audio file</label>
<label class="block text-sm font-medium text-stone-700">Audio files</label>
<div
@dragenter.prevent="dragging = true"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop($event)"
@click="$refs.fileInput.click()"
:class="dragging ? 'border-teal-600 bg-teal-50' : 'border-stone-300 bg-stone-50 hover:border-teal-500 hover:bg-teal-50/40'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded border-2 border-dashed px-6 py-12 text-center transition-colors"
>
<p class="text-sm font-medium text-stone-800">Drop audio files here</p>
<p class="mt-1 text-sm text-stone-600">or click to browse</p>
<p class="mt-3 text-xs text-stone-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 100 MB each · up to 50 files</p>
</div>
<input
x-ref="fileInput"
id="audio"
type="file"
name="audio"
name="audio[]"
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
required
class="mt-2 block w-full text-sm text-stone-600 file:mr-4 file:rounded file:border-0 file:bg-teal-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-teal-800 hover:file:bg-teal-100"
multiple
class="sr-only"
@change="onBrowse($event)"
>
<p class="mt-2 text-xs text-stone-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF (max 100 MB).</p>
</div>
<div>
<div x-show="files.length > 0" x-cloak class="space-y-2">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-stone-700">
<span x-text="files.length"></span>
<span x-text="files.length === 1 ? 'file selected' : 'files selected'"></span>
</p>
<button type="button" @click="clearFiles()" class="text-sm text-stone-600 hover:underline">
Clear all
</button>
</div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200">
<template x-for="(file, index) in files" :key="fileKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0">
<p class="truncate font-medium text-stone-800" x-text="file.name"></p>
<p class="text-xs text-stone-500" x-text="formatSize(file.size)"></p>
</div>
<button
type="button"
@click="removeFile(index)"
class="shrink-0 text-stone-500 hover:text-red-700"
>
Remove
</button>
</li>
</template>
</ul>
</div>
<div x-show="files.length === 1" x-cloak>
<label for="title" class="block text-sm font-medium text-stone-700">Title (optional)</label>
<input
id="title"
@@ -41,11 +91,130 @@
>
</div>
<p x-show="error" x-cloak class="text-sm text-red-700" x-text="error"></p>
<div class="flex items-center gap-3">
<button type="submit" class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800">
Upload
<button
type="submit"
:disabled="files.length === 0 || uploading"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<span x-text="uploadLabel"></span>
</button>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
</div>
</form>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak]{display:none!important}</style>
<script>
function uploadDropzone() {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
return {
files: [],
dragging: false,
uploading: false,
error: null,
get uploadLabel() {
if (this.uploading) return 'Uploading…';
if (this.files.length <= 1) return 'Upload';
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
const accepted = [];
for (const file of incoming) {
if (! this.isAccepted(file)) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
continue;
}
if (file.size > 100 * 1024 * 1024) {
this.error = 'Skipped a file larger than 100 MB.';
continue;
}
accepted.push(file);
}
const merged = [...this.files, ...accepted];
const unique = [];
const seen = new Set();
for (const file of merged) {
const key = this.fileKey(file);
if (seen.has(key)) continue;
seen.add(key);
unique.push(file);
}
if (unique.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = unique.slice(0, 50);
} else {
this.files = unique;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) return true;
return (file.type || '').startsWith('audio/');
},
fileKey(file, index = 0) {
return [file.name, file.size, file.lastModified, index].join(':');
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) return;
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
},
};
}
</script>
@endsection
+30 -13
View File
@@ -8,18 +8,28 @@
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p>
</div>
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
Search
</button>
</form>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
Search
</button>
</form>
@if (($pendingCount ?? 0) > 0)
<form method="POST" action="{{ route('recordings.transcribe-pending') }}">
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline">
Queue {{ $pendingCount }} pending {{ \Illuminate\Support\Str::plural('transcription', $pendingCount) }}
</button>
</form>
@endif
</div>
</div>
@if ($recordings->isEmpty())
@@ -36,6 +46,7 @@
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
@@ -57,8 +68,14 @@
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
<td class="px-4 py-3 tabular-nums text-stone-600">
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
@include('recordings.partials.status-badge', [
'status' => $recording->transcription_status,
'label' => $recording->transcriptionStatusLabel(),
])
@if ($recording->isTranscribing() && $recording->transcription_progress)
<div class="mt-1 max-w-[14rem] truncate text-xs text-amber-700" title="{{ $recording->transcription_progress }}">
{{ $recording->transcription_percent ? $recording->transcription_percent.'% · ' : '' }}{{ $recording->transcription_progress }}
@@ -1,4 +1,12 @@
@php
$label = $label ?? match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$classes = match ($status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
@@ -9,5 +17,5 @@
};
@endphp
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
{{ $status }}
{{ $label }}
</span>
+2 -8
View File
@@ -18,7 +18,7 @@
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="badgeClass"
x-text="status.status"
x-text="status.status_label || status.status"
></span>
<template x-if="status.driver_label">
<span class="text-xs text-stone-500" x-text="status.driver_label"></span>
@@ -71,12 +71,6 @@
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2>
<p class="mt-2 text-sm text-stone-600">
Audio stays on this machine. Requires
<code class="text-[11px]">docker compose up -d whisper</code>
(port 8090).
</p>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
@csrf
<button
@@ -175,7 +169,7 @@
x-cloak
class="mt-4 text-sm text-stone-500"
>
No transcript yet. Choose an engine above to start.
No transcript yet. Transcription starts automatically after upload, or use the button above.
</p>
</section>
</div>