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:
@@ -14,6 +14,8 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
|
||||
- Stop or restart a run anytime
|
||||
- Copy finished transcripts from the recording detail page
|
||||
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 8.3+ (8.5 recommended)
|
||||
@@ -22,6 +24,8 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
|
||||
- SQLite (default) or another supported database
|
||||
- [Docker](https://docs.docker.com/get-docker/) for the Whisper container
|
||||
|
||||
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
@@ -42,6 +46,8 @@ npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Local Whisper (Docker)
|
||||
|
||||
Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that:
|
||||
@@ -70,19 +76,23 @@ Stop:
|
||||
docker compose down
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy values from `.env.example`. The transcription-related settings are:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://127.0.0.1:8090/v1`) |
|
||||
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
|
||||
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
|
||||
| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) |
|
||||
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
|
||||
| `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) |
|
||||
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
|
||||
|
||||
| Variable | Purpose |
|
||||
| ----------------------- | -------------------------------------------------------------------------------- |
|
||||
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://127.0.0.1:8090/v1`) |
|
||||
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
|
||||
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
|
||||
| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) |
|
||||
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
|
||||
| `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) |
|
||||
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
|
||||
|
||||
|
||||
Finished transcripts are stored on the recording (`transcript` column) and are included in the recordings search box (title, artist, album, filename, and transcript).
|
||||
|
||||
@@ -112,10 +122,13 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Upload** audio from Recordings → Upload (optional title override).
|
||||
2. Open the recording and start transcription.
|
||||
3. Watch live progress until the transcript appears (or stop and restart).
|
||||
1. **Upload** audio from Recordings → Upload (single file or batch dropzone).
|
||||
2. Transcription queues automatically — keep `php artisan queue:work` running.
|
||||
3. Watch live progress on the recording page (or stop and restart).
|
||||
4. Search the list by title, artist, or transcript text.
|
||||
5. If older uploads still show **Queued** with no progress, use **Queue pending transcriptions** on the recordings list.
|
||||
|
||||
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -125,6 +138,8 @@ composer test
|
||||
php artisan test
|
||||
```
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT
|
||||
@@ -7,6 +7,7 @@ use App\Models\Recording;
|
||||
use App\Services\Mp3MetadataService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -30,9 +31,16 @@ class RecordingController extends Controller
|
||||
|
||||
$recordings = $query->paginate(20)->withQueryString();
|
||||
|
||||
$pendingCount = Recording::query()
|
||||
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
|
||||
->get()
|
||||
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
|
||||
->count();
|
||||
|
||||
return view('recordings.index', [
|
||||
'recordings' => $recordings,
|
||||
'search' => $search ?? '',
|
||||
'pendingCount' => $pendingCount,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -45,33 +53,36 @@ class RecordingController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly uploaded recording.
|
||||
* Store one or more uploaded recordings.
|
||||
*/
|
||||
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
|
||||
{
|
||||
$file = $request->file('audio');
|
||||
$path = $file->store('recordings', 'local');
|
||||
$absolutePath = Storage::disk('local')->path($path);
|
||||
$tags = $metadata->extract($absolutePath);
|
||||
/** @var list<UploadedFile> $files */
|
||||
$files = array_values(array_filter(
|
||||
$request->file('audio', []),
|
||||
fn ($file) => $file instanceof UploadedFile,
|
||||
));
|
||||
|
||||
$title = $request->string('title')->trim()->toString()
|
||||
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
|
||||
$titleOverride = $request->string('title')->trim()->toString();
|
||||
$recordings = [];
|
||||
|
||||
$recording = Recording::create([
|
||||
'title' => $title,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'duration_seconds' => $tags['duration_seconds'],
|
||||
'recorded_at' => $tags['recorded_at'],
|
||||
'artist' => $tags['artist'],
|
||||
'album' => $tags['album'],
|
||||
'file_size_bytes' => $file->getSize() ?: 0,
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
foreach ($files as $file) {
|
||||
$title = count($files) === 1 && $titleOverride !== ''
|
||||
? $titleOverride
|
||||
: null;
|
||||
|
||||
$recordings[] = $this->storeUploadedRecording($file, $metadata, $title);
|
||||
}
|
||||
|
||||
if (count($recordings) === 1) {
|
||||
return redirect()
|
||||
->route('recordings.show', $recordings[0])
|
||||
->with('success', 'Recording uploaded — transcription queued.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('recordings.show', $recording)
|
||||
->with('success', 'Recording uploaded successfully.');
|
||||
->route('recordings.index')
|
||||
->with('success', count($recordings).' recordings uploaded — transcription queued.');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,4 +108,37 @@ class RecordingController extends Controller
|
||||
->route('recordings.index')
|
||||
->with('success', 'Recording deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a single uploaded audio file as a recording.
|
||||
*/
|
||||
private function storeUploadedRecording(
|
||||
UploadedFile $file,
|
||||
Mp3MetadataService $metadata,
|
||||
?string $titleOverride = null,
|
||||
): Recording {
|
||||
$path = $file->store('recordings', 'local');
|
||||
$absolutePath = Storage::disk('local')->path($path);
|
||||
$tags = $metadata->extract($absolutePath);
|
||||
|
||||
$title = $titleOverride
|
||||
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
|
||||
|
||||
$recording = Recording::create([
|
||||
'title' => $title,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'duration_seconds' => $tags['duration_seconds'],
|
||||
'recorded_at' => $tags['recorded_at'],
|
||||
'artist' => $tags['artist'],
|
||||
'album' => $tags['album'],
|
||||
'file_size_bytes' => $file->getSize() ?: 0,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
$recording->queueLocalTranscription();
|
||||
|
||||
return $recording->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\TranscribeRecordingRequest;
|
||||
use App\Jobs\TranscribeRecording;
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@@ -16,24 +15,7 @@ class TranscribeController extends Controller
|
||||
*/
|
||||
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
|
||||
{
|
||||
if ($recording->isTranscribing() || $recording->hasActiveTranscriptionJob()) {
|
||||
$recording->cancelTranscription(silent: true);
|
||||
$recording->refresh();
|
||||
}
|
||||
|
||||
$recording->update([
|
||||
'transcription_driver' => 'local',
|
||||
'ollama_url' => null,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_progress' => 'Queued — waiting to start…',
|
||||
'transcription_percent' => 5,
|
||||
'transcription_started_at' => now(),
|
||||
'transcription_error' => null,
|
||||
// Keep the previous transcript until a new run succeeds.
|
||||
'transcribed_at' => $recording->transcribed_at,
|
||||
]);
|
||||
|
||||
TranscribeRecording::dispatch($recording->fresh());
|
||||
$recording->queueLocalTranscription();
|
||||
|
||||
return back()->with('success', 'Transcription started. Progress updates below.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class TranscribePendingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Queue local transcription for recordings that still need a transcript.
|
||||
*/
|
||||
public function __invoke(): RedirectResponse
|
||||
{
|
||||
$queued = 0;
|
||||
|
||||
Recording::query()
|
||||
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
|
||||
->orderBy('id')
|
||||
->each(function (Recording $recording) use (&$queued): void {
|
||||
if ($recording->hasActiveTranscriptionJob()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recording->queueLocalTranscription();
|
||||
$queued++;
|
||||
});
|
||||
|
||||
if ($queued === 0) {
|
||||
return back()->with('error', 'No recordings need transcription right now.');
|
||||
}
|
||||
|
||||
return back()->with(
|
||||
'success',
|
||||
$queued === 1
|
||||
? 'Queued 1 recording for transcription.'
|
||||
: "Queued {$queued} recordings for transcription.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Validation\Rules\File;
|
||||
|
||||
class StoreRecordingRequest extends FormRequest
|
||||
@@ -34,13 +35,24 @@ class StoreRecordingRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single file upload into an array for batch handling.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->hasFile('audio') && $this->file('audio') instanceof UploadedFile) {
|
||||
$this->files->set('audio', [$this->file('audio')]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'audio' => [
|
||||
'audio' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'audio.*' => [
|
||||
'required',
|
||||
File::types(self::AUDIO_EXTENSIONS)->max(102400),
|
||||
],
|
||||
@@ -54,9 +66,12 @@ class StoreRecordingRequest extends FormRequest
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'audio.required' => 'Please choose an audio file to upload.',
|
||||
'audio' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
|
||||
'audio.max' => 'The audio file may not be larger than 100 MB.',
|
||||
'audio.required' => 'Please choose at least one audio file to upload.',
|
||||
'audio.min' => 'Please choose at least one audio file to upload.',
|
||||
'audio.max' => 'You can upload at most 50 files at once.',
|
||||
'audio.*.required' => 'Please choose an audio file to upload.',
|
||||
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
|
||||
'audio.*.max' => 'Each audio file may not be larger than 100 MB.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,20 @@ class Recording extends Model
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Word count of the stored transcript (0 when empty).
|
||||
*/
|
||||
protected function wordCount(): Attribute
|
||||
{
|
||||
return Attribute::get(function (): int {
|
||||
if (! filled($this->transcript)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return count(preg_split('/\s+/u', trim($this->transcript), -1, PREG_SPLIT_NO_EMPTY) ?: []);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly label for the selected transcription engine.
|
||||
*/
|
||||
@@ -92,6 +106,47 @@ class Recording extends Model
|
||||
return in_array($this->transcription_status, ['pending', 'processing'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a new local faster-whisper transcription run.
|
||||
*/
|
||||
public function queueLocalTranscription(): void
|
||||
{
|
||||
// Only stop a real in-flight/queued run — bare "pending" uploads have no job yet.
|
||||
if ($this->transcription_status === 'processing' || $this->hasActiveTranscriptionJob()) {
|
||||
$this->cancelTranscription(silent: true);
|
||||
$this->refresh();
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'transcription_driver' => 'local',
|
||||
'ollama_url' => null,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_progress' => 'Queued — waiting to start…',
|
||||
'transcription_percent' => 5,
|
||||
'transcription_started_at' => now(),
|
||||
'transcription_error' => null,
|
||||
// Keep the previous transcript until a new run succeeds.
|
||||
'transcribed_at' => $this->transcribed_at,
|
||||
]);
|
||||
|
||||
TranscribeRecording::dispatch($this->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable transcription status for badges.
|
||||
*/
|
||||
public function transcriptionStatusLabel(): string
|
||||
{
|
||||
return match ($this->transcription_status) {
|
||||
'pending' => 'Queued',
|
||||
'processing' => 'Transcribing',
|
||||
'done' => 'Done',
|
||||
'failed' => 'Failed',
|
||||
'cancelled' => 'Cancelled',
|
||||
default => (string) $this->transcription_status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search title, metadata, and stored transcript text.
|
||||
*/
|
||||
@@ -383,6 +438,7 @@ class Recording extends Model
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'status' => $this->transcription_status,
|
||||
'status_label' => $this->transcriptionStatusLabel(),
|
||||
'progress' => $this->transcription_progress,
|
||||
'percent' => $this->transcription_percent,
|
||||
'driver' => $this->transcription_driver,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
use App\Http\Controllers\CancelTranscriptionController;
|
||||
use App\Http\Controllers\RecordingController;
|
||||
use App\Http\Controllers\TranscribeController;
|
||||
use App\Http\Controllers\TranscribePendingController;
|
||||
use App\Http\Controllers\TranscriptionStatusController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::redirect('/', '/recordings');
|
||||
|
||||
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
|
||||
Route::post('recordings/transcribe-pending', TranscribePendingController::class)
|
||||
->name('recordings.transcribe-pending');
|
||||
Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe');
|
||||
Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class)
|
||||
->name('recordings.transcribe.cancel');
|
||||
|
||||
@@ -23,22 +23,26 @@ class RecordingUploadTest extends TestCase
|
||||
'original_filename' => 'note.mp3',
|
||||
'file_path' => 'recordings/note.mp3',
|
||||
'file_size_bytes' => 1024,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_status' => 'done',
|
||||
'transcript' => 'One two three four five',
|
||||
]);
|
||||
|
||||
$this->get(route('recordings.index'))
|
||||
->assertOk()
|
||||
->assertSee('Pocket note');
|
||||
->assertSee('Pocket note')
|
||||
->assertSee('Words')
|
||||
->assertSee('5');
|
||||
}
|
||||
|
||||
public function test_user_can_upload_an_mp3(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
|
||||
|
||||
$response = $this->post(route('recordings.store'), [
|
||||
'audio' => $file,
|
||||
'audio' => [$file],
|
||||
'title' => 'Team meeting',
|
||||
]);
|
||||
|
||||
@@ -48,12 +52,48 @@ class RecordingUploadTest extends TestCase
|
||||
$response->assertRedirect(route('recordings.show', $recording));
|
||||
$this->assertSame('Team meeting', $recording->title);
|
||||
$this->assertSame('pending', $recording->transcription_status);
|
||||
$this->assertSame('local', $recording->transcription_driver);
|
||||
$this->assertNotNull($recording->transcription_started_at);
|
||||
Storage::disk('local')->assertExists($recording->file_path);
|
||||
Bus::assertDispatched(TranscribeRecording::class);
|
||||
}
|
||||
|
||||
public function test_user_can_batch_upload_multiple_audio_files(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$response = $this->post(route('recordings.store'), [
|
||||
'audio' => [
|
||||
UploadedFile::fake()->create('one.mp3', 400, 'audio/mpeg'),
|
||||
UploadedFile::fake()->create('two.wav', 400, 'audio/wav'),
|
||||
UploadedFile::fake()->create('three.ogg', 400, 'audio/ogg'),
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('recordings.index'));
|
||||
$response->assertSessionHas('success');
|
||||
$this->assertSame(3, Recording::query()->count());
|
||||
Bus::assertDispatched(TranscribeRecording::class, 3);
|
||||
|
||||
foreach (Recording::query()->get() as $recording) {
|
||||
Storage::disk('local')->assertExists($recording->file_path);
|
||||
$this->assertSame('pending', $recording->transcription_status);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_upload_page_includes_dropzone(): void
|
||||
{
|
||||
$this->get(route('recordings.create'))
|
||||
->assertOk()
|
||||
->assertSee('Drop audio files here')
|
||||
->assertSee('name="audio[]"', false);
|
||||
}
|
||||
|
||||
public function test_user_can_upload_wav_and_ogg(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
foreach ([
|
||||
['memo.wav', 'audio/wav'],
|
||||
@@ -61,7 +101,7 @@ class RecordingUploadTest extends TestCase
|
||||
['talk.m4a', 'audio/mp4'],
|
||||
] as [$name, $mime]) {
|
||||
$response = $this->post(route('recordings.store'), [
|
||||
'audio' => UploadedFile::fake()->create($name, 400, $mime),
|
||||
'audio' => [UploadedFile::fake()->create($name, 400, $mime)],
|
||||
'title' => $name,
|
||||
]);
|
||||
|
||||
@@ -71,6 +111,8 @@ class RecordingUploadTest extends TestCase
|
||||
$response->assertRedirect(route('recordings.show', $recording));
|
||||
Storage::disk('local')->assertExists($recording->file_path);
|
||||
}
|
||||
|
||||
Bus::assertDispatched(TranscribeRecording::class, 3);
|
||||
}
|
||||
|
||||
public function test_unsupported_audio_type_is_rejected(): void
|
||||
@@ -79,9 +121,9 @@ class RecordingUploadTest extends TestCase
|
||||
|
||||
$this->from(route('recordings.create'))
|
||||
->post(route('recordings.store'), [
|
||||
'audio' => UploadedFile::fake()->create('notes.txt', 10, 'text/plain'),
|
||||
'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')],
|
||||
])
|
||||
->assertSessionHasErrors('audio');
|
||||
->assertSessionHasErrors(['audio.0']);
|
||||
}
|
||||
|
||||
public function test_user_can_queue_local_transcription(): void
|
||||
@@ -385,4 +427,49 @@ class RecordingUploadTest extends TestCase
|
||||
$this->assertSame('pending', $recording->transcription_status);
|
||||
Bus::assertDispatched(TranscribeRecording::class);
|
||||
}
|
||||
|
||||
public function test_user_can_queue_all_pending_transcriptions(): void
|
||||
{
|
||||
Bus::fake();
|
||||
|
||||
Recording::query()->create([
|
||||
'title' => 'Needs work',
|
||||
'original_filename' => 'needs.mp3',
|
||||
'file_path' => 'recordings/needs.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
Recording::query()->create([
|
||||
'title' => 'Already done',
|
||||
'original_filename' => 'done.mp3',
|
||||
'file_path' => 'recordings/done.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'done',
|
||||
'transcript' => 'Finished text',
|
||||
]);
|
||||
|
||||
$this->from(route('recordings.index'))
|
||||
->post(route('recordings.transcribe-pending'))
|
||||
->assertRedirect(route('recordings.index'))
|
||||
->assertSessionHas('success');
|
||||
|
||||
Bus::assertDispatched(TranscribeRecording::class, 1);
|
||||
}
|
||||
|
||||
public function test_index_shows_human_status_labels(): void
|
||||
{
|
||||
Recording::query()->create([
|
||||
'title' => 'Label check',
|
||||
'original_filename' => 'label.mp3',
|
||||
'file_path' => 'recordings/label.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->get(route('recordings.index'))
|
||||
->assertOk()
|
||||
->assertSee('Queued')
|
||||
->assertDontSee('>pending<', false);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user