From 34ccf0c32bf5c3e257bb60468248676284262475 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 19:17:52 +0200 Subject: [PATCH] Move recordings UI to Livewire pages with Flux components. Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively. --- app/Actions/StoreUploadedRecordings.php | 130 ++++++++++ .../CancelTranscriptionController.php | 26 -- app/Http/Controllers/RecordingController.php | 223 ------------------ .../Controllers/StreamRecordingController.php | 34 +++ app/Http/Controllers/TranscribeController.php | 25 -- .../TranscribePendingController.php | 41 ---- .../Requests/TranscribeRecordingRequest.php | 23 -- app/Livewire/Recordings/Create.php | 18 ++ app/Livewire/Recordings/Index.php | 90 +++++++ app/Livewire/Recordings/Show.php | 70 ++++++ app/Livewire/UploadRecordings.php | 92 ++++++++ app/Models/Recording.php | 20 ++ config/livewire.php | 8 +- resources/js/app.js | 2 - resources/js/transcription.js | 84 ++++++- resources/js/upload.js | 165 ------------- .../views/components/disk-space-bar.blade.php | 41 ++-- .../transcription-status-badge.blade.php | 51 ++++ resources/views/layouts/app.blade.php | 48 ++-- resources/views/layouts/app/header.blade.php | 3 +- .../livewire/recordings/create.blade.php | 11 + .../views/livewire/recordings/index.blade.php | 160 +++++++++++++ .../views/livewire/recordings/show.blade.php | 210 +++++++++++++++++ .../livewire/upload-recordings.blade.php | 67 ++++++ resources/views/recordings/create.blade.php | 109 --------- resources/views/recordings/index.blade.php | 146 ------------ .../partials/status-badge.blade.php | 21 -- resources/views/recordings/show.blade.php | 179 -------------- routes/web.php | 20 +- tests/Feature/DiskSpaceTest.php | 2 +- tests/Feature/RecordingAudioStreamTest.php | 115 +++++++++ .../Feature/RecordingDuplicateUploadTest.php | 45 ++-- tests/Feature/RecordingOwnershipTest.php | 7 +- tests/Feature/RecordingUploadTest.php | 81 +++---- tests/Feature/Recordings/IndexTest.php | 90 +++++++ tests/Feature/Recordings/ShowTest.php | 62 +++++ .../Feature/UploadRecordingsLivewireTest.php | 66 ++++++ 37 files changed, 1482 insertions(+), 1103 deletions(-) create mode 100644 app/Actions/StoreUploadedRecordings.php delete mode 100644 app/Http/Controllers/CancelTranscriptionController.php delete mode 100644 app/Http/Controllers/RecordingController.php create mode 100644 app/Http/Controllers/StreamRecordingController.php delete mode 100644 app/Http/Controllers/TranscribeController.php delete mode 100644 app/Http/Controllers/TranscribePendingController.php delete mode 100644 app/Http/Requests/TranscribeRecordingRequest.php create mode 100644 app/Livewire/Recordings/Create.php create mode 100644 app/Livewire/Recordings/Index.php create mode 100644 app/Livewire/Recordings/Show.php create mode 100644 app/Livewire/UploadRecordings.php delete mode 100644 resources/js/upload.js create mode 100644 resources/views/components/transcription-status-badge.blade.php create mode 100644 resources/views/livewire/recordings/create.blade.php create mode 100644 resources/views/livewire/recordings/index.blade.php create mode 100644 resources/views/livewire/recordings/show.blade.php create mode 100644 resources/views/livewire/upload-recordings.blade.php delete mode 100644 resources/views/recordings/create.blade.php delete mode 100644 resources/views/recordings/index.blade.php delete mode 100644 resources/views/recordings/partials/status-badge.blade.php delete mode 100644 resources/views/recordings/show.blade.php create mode 100644 tests/Feature/RecordingAudioStreamTest.php create mode 100644 tests/Feature/Recordings/IndexTest.php create mode 100644 tests/Feature/Recordings/ShowTest.php create mode 100644 tests/Feature/UploadRecordingsLivewireTest.php diff --git a/app/Actions/StoreUploadedRecordings.php b/app/Actions/StoreUploadedRecordings.php new file mode 100644 index 0000000..a6ff517 --- /dev/null +++ b/app/Actions/StoreUploadedRecordings.php @@ -0,0 +1,130 @@ + $files + * @return array{ + * recordings: list, + * skipped_duplicates: int, + * message: string, + * } + */ + public function handle(User $user, array $files, ?string $titleOverride = null): array + { + $recordings = []; + $skippedDuplicates = 0; + $seenHashes = []; + + foreach ($files as $file) { + if (! $file instanceof UploadedFile) { + continue; + } + + $hash = hash_file('sha256', $file->getRealPath()); + + if ($hash === false) { + continue; + } + + if ( + isset($seenHashes[$hash]) + || $user->recordings()->where('content_hash', $hash)->exists() + || $user->recordings() + ->where('original_filename', $file->getClientOriginalName()) + ->where('file_size_bytes', $file->getSize() ?: 0) + ->exists() + ) { + $skippedDuplicates++; + + continue; + } + + $seenHashes[$hash] = true; + + $title = count($files) === 1 && filled($titleOverride) + ? $titleOverride + : null; + + $recordings[] = $this->storeUploadedRecording($user, $file, $hash, $title); + } + + $message = $this->message(count($recordings), $skippedDuplicates); + + return [ + 'recordings' => $recordings, + 'skipped_duplicates' => $skippedDuplicates, + 'message' => $message, + ]; + } + + private function storeUploadedRecording( + User $user, + UploadedFile $file, + string $contentHash, + ?string $titleOverride = null, + ): Recording { + $path = $file->store('recordings', 'local'); + $absolutePath = Storage::disk('local')->path($path); + $tags = $this->metadata->extract($absolutePath); + + $title = $titleOverride + ?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME)); + + $recording = Recording::query()->create([ + 'user_id' => $user->id, + '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, + 'content_hash' => $contentHash, + 'transcription_status' => 'pending', + 'transcription_driver' => 'local', + ]); + + $recording->queueLocalTranscription(); + + return $recording->fresh(); + } + + private function message(int $savedCount, int $skippedDuplicates): string + { + if ($savedCount === 0 && $skippedDuplicates > 0) { + return $skippedDuplicates === 1 + ? 'That file is already uploaded — nothing new was saved.' + : "All {$skippedDuplicates} files were duplicates — nothing new was saved."; + } + + if ($savedCount === 0) { + return 'No valid audio files were uploaded.'; + } + + $message = $savedCount === 1 + ? 'Recording uploaded — transcription queued.' + : $savedCount.' recordings uploaded — transcription queued.'; + + if ($skippedDuplicates > 0) { + $message .= $skippedDuplicates === 1 + ? ' Skipped 1 duplicate.' + : " Skipped {$skippedDuplicates} duplicates."; + } + + return $message; + } +} diff --git a/app/Http/Controllers/CancelTranscriptionController.php b/app/Http/Controllers/CancelTranscriptionController.php deleted file mode 100644 index d605b9c..0000000 --- a/app/Http/Controllers/CancelTranscriptionController.php +++ /dev/null @@ -1,26 +0,0 @@ -isTranscribing()) { - return back()->with('error', 'No transcription is currently running.'); - } - - $recording->cancelTranscription(); - - return back()->with('success', 'Transcription stopped.'); - } -} diff --git a/app/Http/Controllers/RecordingController.php b/app/Http/Controllers/RecordingController.php deleted file mode 100644 index e2e4f53..0000000 --- a/app/Http/Controllers/RecordingController.php +++ /dev/null @@ -1,223 +0,0 @@ -user(); - - $user->recordings() - ->whereIn('transcription_status', ['pending', 'processing']) - ->orderBy('id') - ->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription()); - - $query = $user->recordings()->latest(); - - if ($search = $request->string('q')->trim()->toString()) { - $query->search($search); - } - - $recordings = $query->paginate(20)->withQueryString(); - - $pendingCount = $user->recordings() - ->whereIn('transcription_status', ['pending', 'failed', 'cancelled']) - ->get() - ->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob()) - ->count(); - - return view('recordings.index', [ - 'recordings' => $recordings, - 'search' => $search ?? '', - 'pendingCount' => $pendingCount, - ]); - } - - /** - * Show the upload form. - */ - public function create(Request $request): View - { - $existingFingerprints = $request->user()->recordings() - ->get(['original_filename', 'file_size_bytes']) - ->map(fn (Recording $recording) => $this->uploadFingerprint( - $recording->original_filename, - (int) $recording->file_size_bytes, - )) - ->unique() - ->values() - ->all(); - - return view('recordings.create', [ - 'existingFingerprints' => $existingFingerprints, - ]); - } - - /** - * Store one or more uploaded recordings. - */ - public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse - { - /** @var list $files */ - $files = array_values(array_filter( - $request->file('audio', []), - fn ($file) => $file instanceof UploadedFile, - )); - - $user = $request->user(); - $titleOverride = $request->string('title')->trim()->toString(); - $recordings = []; - $skippedDuplicates = 0; - $seenHashes = []; - - foreach ($files as $file) { - $hash = hash_file('sha256', $file->getRealPath()); - - if ($hash === false) { - continue; - } - - if ( - isset($seenHashes[$hash]) - || $user->recordings()->where('content_hash', $hash)->exists() - || $user->recordings() - ->where('original_filename', $file->getClientOriginalName()) - ->where('file_size_bytes', $file->getSize() ?: 0) - ->exists() - ) { - $skippedDuplicates++; - - continue; - } - - $seenHashes[$hash] = true; - - $title = count($files) === 1 && $titleOverride !== '' - ? $titleOverride - : null; - - $recordings[] = $this->storeUploadedRecording($request, $file, $metadata, $hash, $title); - } - - if ($recordings === [] && $skippedDuplicates > 0) { - return redirect() - ->route('recordings.create') - ->with('error', $skippedDuplicates === 1 - ? 'That file is already uploaded — nothing new was saved.' - : "All {$skippedDuplicates} files were duplicates — nothing new was saved."); - } - - if ($recordings === []) { - return redirect() - ->route('recordings.create') - ->with('error', 'No valid audio files were uploaded.'); - } - - $message = count($recordings) === 1 - ? 'Recording uploaded — transcription queued.' - : count($recordings).' recordings uploaded — transcription queued.'; - - if ($skippedDuplicates > 0) { - $message .= $skippedDuplicates === 1 - ? ' Skipped 1 duplicate.' - : " Skipped {$skippedDuplicates} duplicates."; - } - - if (count($recordings) === 1) { - return redirect() - ->route('recordings.show', $recordings[0]) - ->with('success', $message); - } - - return redirect() - ->route('recordings.index') - ->with('success', $message); - } - - /** - * Display the specified recording. - */ - public function show(Recording $recording): View - { - Gate::authorize('view', $recording); - - $recording->recoverOrphanedTranscription(); - $recording->refresh(); - - return view('recordings.show', compact('recording')); - } - - /** - * Remove the specified recording. - */ - public function destroy(Recording $recording): RedirectResponse - { - Gate::authorize('delete', $recording); - - $recording->deleteFile(); - $recording->delete(); - - return redirect() - ->route('recordings.index') - ->with('success', 'Recording deleted.'); - } - - /** - * Persist a single uploaded audio file as a recording. - */ - private function storeUploadedRecording( - Request $request, - UploadedFile $file, - Mp3MetadataService $metadata, - string $contentHash, - ?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([ - 'user_id' => $request->user()->id, - '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, - 'content_hash' => $contentHash, - 'transcription_status' => 'pending', - 'transcription_driver' => 'local', - ]); - - $recording->queueLocalTranscription(); - - return $recording->fresh(); - } - - /** - * Client-side fingerprint for name + size duplicate checks before upload. - */ - private function uploadFingerprint(string $filename, int $sizeBytes): string - { - return strtolower($filename).':'.$sizeBytes; - } -} diff --git a/app/Http/Controllers/StreamRecordingController.php b/app/Http/Controllers/StreamRecordingController.php new file mode 100644 index 0000000..1a0864d --- /dev/null +++ b/app/Http/Controllers/StreamRecordingController.php @@ -0,0 +1,34 @@ +file_path && Storage::disk('local')->exists($recording->file_path), + 404, + ); + + return Storage::disk('local')->response( + $recording->file_path, + $recording->original_filename, + [ + 'Content-Type' => $recording->audioMimeType(), + 'Accept-Ranges' => 'bytes', + ], + 'inline', + ); + } +} diff --git a/app/Http/Controllers/TranscribeController.php b/app/Http/Controllers/TranscribeController.php deleted file mode 100644 index 996d7be..0000000 --- a/app/Http/Controllers/TranscribeController.php +++ /dev/null @@ -1,25 +0,0 @@ -queueLocalTranscription(); - - return back()->with('success', 'Transcription started. Progress updates below.'); - } -} diff --git a/app/Http/Controllers/TranscribePendingController.php b/app/Http/Controllers/TranscribePendingController.php deleted file mode 100644 index 8dae6f2..0000000 --- a/app/Http/Controllers/TranscribePendingController.php +++ /dev/null @@ -1,41 +0,0 @@ -user()->recordings() - ->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.", - ); - } -} diff --git a/app/Http/Requests/TranscribeRecordingRequest.php b/app/Http/Requests/TranscribeRecordingRequest.php deleted file mode 100644 index fb6d5b8..0000000 --- a/app/Http/Requests/TranscribeRecordingRequest.php +++ /dev/null @@ -1,23 +0,0 @@ -route('recording'); - - return $recording !== null && $this->user()?->can('transcribe', $recording) === true; - } - - /** - * @return array - */ - public function rules(): array - { - return []; - } -} diff --git a/app/Livewire/Recordings/Create.php b/app/Livewire/Recordings/Create.php new file mode 100644 index 0000000..370fde9 --- /dev/null +++ b/app/Livewire/Recordings/Create.php @@ -0,0 +1,18 @@ +resetPage(); + } + + public function queuePending(): void + { + $queued = 0; + + Auth::user()->recordings() + ->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) { + Flux::toast(text: 'No recordings need transcription right now.', variant: 'danger'); + + return; + } + + Flux::toast( + text: $queued === 1 + ? 'Queued 1 recording for transcription.' + : "Queued {$queued} recordings for transcription.", + variant: 'success', + ); + } + + public function render(): View + { + $user = Auth::user(); + + $user->recordings() + ->whereIn('transcription_status', ['pending', 'processing']) + ->orderBy('id') + ->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription()); + + $query = $user->recordings()->latest(); + + $search = trim($this->search); + + if ($search !== '') { + $query->search($search); + } + + $recordings = $query->paginate(20); + + $pendingCount = $user->recordings() + ->whereIn('transcription_status', ['pending', 'failed', 'cancelled']) + ->get() + ->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob()) + ->count(); + + return view('livewire.recordings.index', [ + 'recordings' => $recordings, + 'search' => $search, + 'pendingCount' => $pendingCount, + ]); + } +} diff --git a/app/Livewire/Recordings/Show.php b/app/Livewire/Recordings/Show.php new file mode 100644 index 0000000..22edfb3 --- /dev/null +++ b/app/Livewire/Recordings/Show.php @@ -0,0 +1,70 @@ +recoverOrphanedTranscription(); + $recording->refresh(); + + $this->recording = $recording; + } + + public function startTranscription(): void + { + Gate::authorize('transcribe', $this->recording); + + $this->recording->queueLocalTranscription(); + $this->recording->refresh(); + + Flux::toast(text: 'Transcription started. Progress updates below.', variant: 'success'); + } + + public function cancelTranscription(): void + { + Gate::authorize('transcribe', $this->recording); + + if (! $this->recording->isTranscribing()) { + Flux::toast(text: 'No transcription is currently running.', variant: 'danger'); + + return; + } + + $this->recording->cancelTranscription(); + $this->recording->refresh(); + + Flux::toast(text: 'Transcription stopped.', variant: 'success'); + } + + public function delete(): mixed + { + Gate::authorize('delete', $this->recording); + + $this->recording->deleteFile(); + $this->recording->delete(); + + session()->flash('success', 'Recording deleted.'); + + return $this->redirect(route('recordings.index'), navigate: true); + } + + public function render(): View + { + return view('livewire.recordings.show') + ->title($this->recording->title); + } +} diff --git a/app/Livewire/UploadRecordings.php b/app/Livewire/UploadRecordings.php new file mode 100644 index 0000000..f49625d --- /dev/null +++ b/app/Livewire/UploadRecordings.php @@ -0,0 +1,92 @@ + + */ + public array $audio = []; + + public string $title = ''; + + public bool $saving = false; + + public function updatedAudio(): void + { + if ($this->saving || $this->audio === []) { + return; + } + + $this->save(); + } + + public function save(?StoreUploadedRecordings $store = null): mixed + { + if ($this->saving) { + return null; + } + + $this->saving = true; + + $store ??= app(StoreUploadedRecordings::class); + + $this->validate([ + 'audio' => ['required', 'array', 'min:1', 'max:50'], + 'audio.*' => [ + 'required', + File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'), + ], + 'title' => ['nullable', 'string', 'max:255'], + ], [ + '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.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.', + 'audio.*.max' => 'Each audio file may not be larger than 2 GB.', + ]); + + $result = $store->handle( + Auth::user(), + $this->audio, + filled($this->title) ? $this->title : null, + ); + + $this->audio = []; + $this->title = ''; + $this->saving = false; + + if ($result['recordings'] === []) { + session()->flash('error', $result['message']); + + return $this->redirect(route('recordings.create'), navigate: true); + } + + session()->flash('success', $result['message']); + + if (count($result['recordings']) === 1) { + return $this->redirect( + route('recordings.show', $result['recordings'][0]), + navigate: true, + ); + } + + return $this->redirect(route('recordings.index'), navigate: true); + } + + public function render() + { + return view('livewire.upload-recordings'); + } +} diff --git a/app/Models/Recording.php b/app/Models/Recording.php index 06eeee8..c0168e1 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -444,6 +444,26 @@ class Recording extends Model return Storage::disk('local')->path($this->file_path); } + /** + * MIME type for browser audio playback based on the original filename. + */ + public function audioMimeType(): string + { + $extension = strtolower(pathinfo((string) $this->original_filename, PATHINFO_EXTENSION)); + + return match ($extension) { + 'mp3', 'mpga', 'mpeg' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'ogg', 'oga' => 'audio/ogg', + 'flac' => 'audio/flac', + 'm4a', 'mp4', 'aac' => 'audio/mp4', + 'webm' => 'audio/webm', + 'wma' => 'audio/x-ms-wma', + 'aiff', 'aif' => 'audio/aiff', + default => 'application/octet-stream', + }; + } + /** * Delete the audio file from storage. */ diff --git a/config/livewire.php b/config/livewire.php index e6e0e73..ea3a21a 100644 --- a/config/livewire.php +++ b/config/livewire.php @@ -44,7 +44,7 @@ return [ | */ - 'component_layout' => 'layouts::app.header', + 'component_layout' => 'layouts.app', /* |--------------------------------------------------------------------------- @@ -130,15 +130,17 @@ return [ 'temporary_file_upload' => [ 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default' - 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) + // Pocket-recorder audio can be large; max is kilobytes (2 GiB). + 'rules' => ['required', 'file', 'max:2097152'], 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'mov', 'avi', 'wmv', 'mp3', 'm4a', 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + 'ogg', 'oga', 'flac', 'aac', 'webm', 'aiff', 'aif', ], - 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... + 'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated... 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... ], diff --git a/resources/js/app.js b/resources/js/app.js index 2f9ea71..0e7ae87 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,7 +1,5 @@ import './echo'; import { recordingsIndex, transcriptionMonitor } from './transcription'; -import { uploadDropzone } from './upload'; window.transcriptionMonitor = transcriptionMonitor; window.recordingsIndex = recordingsIndex; -window.uploadDropzone = uploadDropzone; diff --git a/resources/js/transcription.js b/resources/js/transcription.js index 0abed6c..e9855cd 100644 --- a/resources/js/transcription.js +++ b/resources/js/transcription.js @@ -2,16 +2,21 @@ * Shared helpers and Alpine components for live transcription updates via Reverb. */ -const BADGE_CLASSES = { - done: 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30', - processing: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', - pending: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', - failed: 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30', - cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30', +const BADGE_COLORS = { + done: 'teal', + processing: 'amber', + pending: 'amber', + failed: 'red', + cancelled: 'zinc', }; +export function badgeColorFor(status) { + return BADGE_COLORS[status] || 'zinc'; +} + +/** @deprecated Use badgeColorFor — kept for any leftover callers */ export function badgeClassFor(status) { - return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30'; + return badgeColorFor(status); } export function formatElapsed(seconds) { @@ -85,13 +90,16 @@ function subscribeToRecordings(recordingIds, handler) { export function transcriptionMonitor({ statusUrl, initial }) { return { statusUrl, - status: initial, + status: { + ...initial, + badge_color: badgeColorFor(initial.status), + }, pollError: null, tickTimer: null, leaveChannel: null, - get badgeClass() { - return badgeClassFor(this.status.status); + get badgeColor() { + return badgeColorFor(this.status.status); }, get startButtonLabel() { @@ -128,7 +136,11 @@ export function transcriptionMonitor({ statusUrl, initial }) { applyPayload(payload) { const wasActive = this.status.is_active; - this.status = { ...this.status, ...payload }; + this.status = { + ...this.status, + ...payload, + badge_color: badgeColorFor(payload.status ?? this.status.status), + }; this.pollError = null; if (this.status.is_active) { @@ -209,6 +221,8 @@ export function recordingsIndex({ recordings, pendingCount }) { rows: byId, pendingCount: Number(pendingCount) || 0, leaveChannel: null, + playingId: null, + isPlaying: false, start() { this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => { @@ -221,6 +235,52 @@ export function recordingsIndex({ recordings, pendingCount }) { this.leaveChannel(); this.leaveChannel = null; } + + const player = this.$refs.player; + + if (player) { + player.pause(); + player.removeAttribute('src'); + player.load(); + } + }, + + syncPlayer() { + const player = this.$refs.player; + + this.isPlaying = Boolean(player && !player.paused && !player.ended); + + if (player?.ended) { + this.playingId = null; + } + }, + + isPlayingRow(id) { + return this.playingId === id && this.isPlaying; + }, + + togglePlay(id, url) { + const player = this.$refs.player; + + if (!player) { + return; + } + + if (this.playingId === id && this.isPlaying) { + player.pause(); + + return; + } + + if (this.playingId !== id) { + player.src = url; + this.playingId = id; + } + + player.play().catch(() => { + this.playingId = null; + this.isPlaying = false; + }); }, applyPayload(payload) { @@ -240,7 +300,7 @@ export function recordingsIndex({ recordings, pendingCount }) { is_active: payload.is_active, word_count: payload.word_count ?? this.rows[id].word_count, word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count), - badge_class: badgeClassFor(payload.status), + badge_color: badgeColorFor(payload.status), }; this.rows[id] = next; diff --git a/resources/js/upload.js b/resources/js/upload.js deleted file mode 100644 index b96ee50..0000000 --- a/resources/js/upload.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Upload dropzone: discard duplicate files in the selection (and known server fingerprints) - * before submitting the form. - */ - -const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024; - -export function uploadDropzone({ existingFingerprints = [] } = {}) { - const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif']; - const known = new Set(existingFingerprints); - - return { - files: [], - dragging: false, - uploading: false, - error: null, - notice: 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; - this.notice = null; - - const accepted = []; - let skippedUnsupported = 0; - let skippedTooLarge = 0; - let skippedDuplicates = 0; - - for (const file of incoming) { - if (! this.isAccepted(file)) { - skippedUnsupported++; - continue; - } - - if (file.size > MAX_FILE_BYTES) { - skippedTooLarge++; - continue; - } - - const fingerprint = this.fingerprint(file); - - if (known.has(fingerprint) || this.files.some((existing) => this.fingerprint(existing) === fingerprint)) { - skippedDuplicates++; - continue; - } - - if (accepted.some((existing) => this.fingerprint(existing) === fingerprint)) { - skippedDuplicates++; - continue; - } - - accepted.push(file); - } - - this.files = [...this.files, ...accepted]; - - if (this.files.length > 50) { - this.error = 'You can upload at most 50 files at once.'; - this.files = this.files.slice(0, 50); - } - - if (skippedUnsupported > 0) { - this.error = 'Skipped unsupported file type. Use common audio formats only.'; - } else if (skippedTooLarge > 0) { - this.error = 'Skipped a file larger than 2 GB.'; - } - - if (skippedDuplicates > 0) { - this.notice = skippedDuplicates === 1 - ? 'Skipped 1 duplicate file.' - : `Skipped ${skippedDuplicates} duplicate files.`; - } - - this.syncInput(); - }, - - isAccepted(file) { - const name = (file.name || '').toLowerCase(); - - if (acceptExt.some((ext) => name.endsWith(ext))) { - return true; - } - - return (file.type || '').startsWith('audio/'); - }, - - fingerprint(file) { - return `${String(file.name || '').toLowerCase()}:${Number(file.size) || 0}`; - }, - - fileListKey(file, index = 0) { - return `${this.fingerprint(file)}:${index}`; - }, - - removeFile(index) { - this.files.splice(index, 1); - this.syncInput(); - }, - - clearFiles() { - this.files = []; - this.notice = null; - 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'; - } - - if (bytes < 1024 * 1024 * 1024) { - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - } - - return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; - }, - }; -} diff --git a/resources/views/components/disk-space-bar.blade.php b/resources/views/components/disk-space-bar.blade.php index 94d5772..a1e672e 100644 --- a/resources/views/components/disk-space-bar.blade.php +++ b/resources/views/components/disk-space-bar.blade.php @@ -1,29 +1,24 @@ @php /** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */ @endphp -
-
-
- Disk space - - {{ $disk['free_human'] }} free - · - {{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }} - -
+
+
-
-
+ class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}" + style="width: {{ min(100, max(0, $disk['used_percent'])) }}%" + >
+
diff --git a/resources/views/components/transcription-status-badge.blade.php b/resources/views/components/transcription-status-badge.blade.php new file mode 100644 index 0000000..9775da2 --- /dev/null +++ b/resources/views/components/transcription-status-badge.blade.php @@ -0,0 +1,51 @@ +@props([ + 'status', + 'label' => null, + /** @var string|null Alpine expression that returns a row object with badge_color + status_label */ + 'alpineRow' => null, +]) + +@php + $label ??= match ($status) { + 'pending' => 'Queued', + 'processing' => 'Transcribing', + 'done' => 'Done', + 'failed' => 'Failed', + 'cancelled' => 'Cancelled', + default => (string) $status, + }; + + $color = match ($status) { + 'done' => 'teal', + 'processing', 'pending' => 'amber', + 'failed' => 'red', + default => 'zinc', + }; +@endphp + +@if ($alpineRow) + class('inline-flex') }}> + @foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor) + @if ($badgeColor === $color) + {{ $label }} + @else + {{ $label }} + @endif + @endforeach + +@else + + {{ $label }} + +@endif diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index c4b8cb2..a8f907e 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1,17 +1,15 @@ - @include('partials.head', ['title' => trim($__env->yieldContent('title')) ?: null]) + @include('partials.head', ['title' => $title ?? null]) - -
- + AndyTranscribe @@ -19,12 +17,14 @@ {{ __('Recordings') }} {{ __('Upload') }} @@ -32,6 +32,8 @@ + + @auth @@ -42,17 +44,17 @@ - + AndyTranscribe - + {{ __('Recordings') }} - + {{ __('Upload') }} @@ -60,30 +62,34 @@
@if (session('success')) -
- {{ session('success') }} -
+ + {{ session('success') }} + @endif @if (session('error')) -
- {{ session('error') }} -
+ + {{ session('error') }} + @endif @if ($errors->any()) -
-
    - @foreach ($errors->all() as $error) -
  • {{ $error }}
  • - @endforeach -
-
+ + +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+
@endif - @yield('content') + {{ $slot }}
+ + @fluxScripts diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php index a7f31da..8595eea 100644 --- a/resources/views/layouts/app/header.blade.php +++ b/resources/views/layouts/app/header.blade.php @@ -5,14 +5,13 @@ - -
AndyTranscribe + @auth diff --git a/resources/views/livewire/recordings/create.blade.php b/resources/views/livewire/recordings/create.blade.php new file mode 100644 index 0000000..85d0c46 --- /dev/null +++ b/resources/views/livewire/recordings/create.blade.php @@ -0,0 +1,11 @@ +
+
+ Upload recordings + + Drop one or many pocket-recorder files. Embedded metadata is extracted when available. + Duplicate files (same name and size, or identical content) are skipped. + +
+ + +
diff --git a/resources/views/livewire/recordings/index.blade.php b/resources/views/livewire/recordings/index.blade.php new file mode 100644 index 0000000..3297a5e --- /dev/null +++ b/resources/views/livewire/recordings/index.blade.php @@ -0,0 +1,160 @@ +
+
+
+ Recordings + Manage pocket-recorder audio and transcripts. +
+
+
+ +
+
+ + Queue pending + + +
+
+
+ + @if ($recordings->isEmpty()) + + @if (filled($search)) + No recordings match “{{ $search }}”. +
+ Clear search +
+ @else + No recordings yet. +
+ Upload your first MP3 +
+ @endif +
+ @else + + + + + + Title + Duration + Words + Status + Uploaded + + + + @foreach ($recordings as $recording) + + + + + + + + + + {{ $recording->title }} + + @if ($recording->artist) + {{ $recording->artist }} + @endif + @if ($snippet = $recording->transcriptSnippet($search ?: null)) + + {{ $snippet }} + + @endif + + {{ $recording->duration_formatted }} + + + {{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }} + + + + +
+ + +
+
+ + {{ $recording->created_at?->format('Y-m-d H:i') }} + +
+ @endforeach +
+
+ @endif +
diff --git a/resources/views/livewire/recordings/show.blade.php b/resources/views/livewire/recordings/show.blade.php new file mode 100644 index 0000000..09b7577 --- /dev/null +++ b/resources/views/livewire/recordings/show.blade.php @@ -0,0 +1,210 @@ +
+
+
+ ← Recordings + {{ $recording->title }} +
+ + +
+
+
+ + Play + + + + Delete + + + +
+
+ Delete recording? + + This permanently removes the recording and its audio file. + +
+
+ + Cancel + + Delete +
+
+
+
+
+ + + Audio + + + +
+ + Metadata +
+
+
Original file
+
{{ $recording->original_filename }}
+
+
+
Duration
+
{{ $recording->duration_formatted }}
+
+
+
Artist
+
{{ $recording->artist ?: '—' }}
+
+
+
Album
+
{{ $recording->album ?: '—' }}
+
+
+
Recorded
+
{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}
+
+
+
Size
+
{{ number_format($recording->file_size_bytes / 1024, 1) }} KB
+
+
+
Uploaded
+
{{ $recording->created_at?->format('Y-m-d H:i') }}
+
+
+
+ + + Transcribe +
+ + + +
+ +
+ + Stop transcription + +
+
+
+ + +
+ Transcript + + Copy + +
+ +
+ + + + + + + · Elapsed + + + +
+ +
+ +
    +
  • + Engine: + +
  • + +
  • +
+
+ +
+ + + Transcription stopped. Use the button above to start again. + + +
+ +
+ + Transcription failed + + + + +
+ +
+

+ +
+ + + No transcript yet. Transcription starts automatically after upload, or use the button above. + +
+
diff --git a/resources/views/livewire/upload-recordings.blade.php b/resources/views/livewire/upload-recordings.blade.php new file mode 100644 index 0000000..0c30bb3 --- /dev/null +++ b/resources/views/livewire/upload-recordings.blade.php @@ -0,0 +1,67 @@ +
+ + +
+ Audio files + +
+
+ +
+ + + Drop audio files here or click to browse + + + + MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files + + +
+
+ + +
+ +
+
+ + @error('audio') + {{ $message }} + @enderror + @error('audio.*') + {{ $message }} + @enderror +
+ + + Uploads start as soon as you drop or choose files. Duplicate files (same name and size, or identical content) are skipped. + + +
+ Cancel +
+
diff --git a/resources/views/recordings/create.blade.php b/resources/views/recordings/create.blade.php deleted file mode 100644 index 9955232..0000000 --- a/resources/views/recordings/create.blade.php +++ /dev/null @@ -1,109 +0,0 @@ -@extends('layouts.app') - -@section('title', 'Upload recording') - -@section('content') -
-

Upload recordings

-

- Drop one or many pocket-recorder files. Embedded metadata is extracted when available. - Duplicate files (same name and size, or identical content) are skipped. -

-
- -
- @csrf - -
- - -
-

Drop audio files here

-

or click to browse

-

MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files

-
- - -
- -
-
-

- - -

- -
- -
    - -
-
- -
- - -
- -

-

- -
- - Cancel -
-
-@endsection diff --git a/resources/views/recordings/index.blade.php b/resources/views/recordings/index.blade.php deleted file mode 100644 index 79916a8..0000000 --- a/resources/views/recordings/index.blade.php +++ /dev/null @@ -1,146 +0,0 @@ -@extends('layouts.app') - -@section('title', 'Recordings') - -@section('content') - @php - $indexRows = $recordings->map(function ($recording) { - return [ - 'id' => $recording->id, - 'status' => $recording->transcription_status, - 'status_label' => $recording->transcriptionStatusLabel(), - 'progress' => $recording->transcription_progress, - 'percent' => $recording->transcription_percent, - 'is_active' => $recording->isTranscribing(), - 'word_count' => $recording->word_count, - 'word_count_display' => $recording->word_count > 0 - ? number_format($recording->word_count) - : '—', - 'badge_class' => match ($recording->transcription_status) { - 'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30', - 'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', - 'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', - 'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30', - 'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30', - default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30', - }, - ]; - })->values(); - @endphp - -
-
-
-

Recordings

-

Manage pocket-recorder audio and transcripts.

-
-
-
- - -
-
- @csrf - -
-
-
- - @if ($recordings->isEmpty()) -
-

No recordings yet.

- - Upload your first MP3 - -
- @else -
- - - - - - - - - - - - @foreach ($recordings as $recording) - - - - - - - - @endforeach - -
TitleDurationWordsStatusUploaded
- - {{ $recording->title }} - - @if ($recording->artist) -
{{ $recording->artist }}
- @endif - @if ($snippet = $recording->transcriptSnippet($search ?: null)) -

- {{ $snippet }} -

- @endif -
{{ $recording->duration_formatted }} - {{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }} - - - {{ $recording->transcriptionStatusLabel() }} - -
- - -
-
{{ $recording->created_at?->format('Y-m-d H:i') }}
-
- -
- {{ $recordings->links() }} -
- @endif -
-@endsection diff --git a/resources/views/recordings/partials/status-badge.blade.php b/resources/views/recordings/partials/status-badge.blade.php deleted file mode 100644 index bcc9bc2..0000000 --- a/resources/views/recordings/partials/status-badge.blade.php +++ /dev/null @@ -1,21 +0,0 @@ -@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 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30', - 'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', - 'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', - 'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30', - 'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30', - default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30', - }; -@endphp - - {{ $label }} - diff --git a/resources/views/recordings/show.blade.php b/resources/views/recordings/show.blade.php deleted file mode 100644 index 934b728..0000000 --- a/resources/views/recordings/show.blade.php +++ /dev/null @@ -1,179 +0,0 @@ -@extends('layouts.app') - -@section('title', $recording->title) - -@section('content') -
-
-
- ← Recordings -

{{ $recording->title }}

-
- - -
-
-
- @csrf - @method('DELETE') - -
-
- -
-
-

Metadata

-
-
-
Original file
-
{{ $recording->original_filename }}
-
-
-
Duration
-
{{ $recording->duration_formatted }}
-
-
-
Artist
-
{{ $recording->artist ?: '—' }}
-
-
-
Album
-
{{ $recording->album ?: '—' }}
-
-
-
Recorded
-
{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}
-
-
-
Size
-
{{ number_format($recording->file_size_bytes / 1024, 1) }} KB
-
-
-
Uploaded
-
{{ $recording->created_at?->format('Y-m-d H:i') }}
-
-
-
- -
-

Transcribe

-
- @csrf - -
- -
- @csrf - -
-
-
- -
-
-

Transcript

- -
- -
-
-

-

- - · - -

-
- -
-
-
- -
    -
  • - Engine: - -
  • - -
  • -
-
- -
- Transcription stopped. Choose an engine above to start again. -
- -
-

Transcription failed

-

-
- -
-

-

-
- -

- No transcript yet. Transcription starts automatically after upload, or use the button above. -

-
-
-@endsection diff --git a/routes/web.php b/routes/web.php index e4bd195..230952c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,22 +1,20 @@ name('home'); Route::middleware('auth')->group(function (): void { - 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'); + Route::get('recordings', Index::class)->name('recordings.index'); + Route::get('recordings/create', Create::class)->name('recordings.create'); + Route::get('recordings/{recording}', Show::class)->name('recordings.show'); + Route::get('recordings/{recording}/audio', StreamRecordingController::class) + ->name('recordings.audio'); Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class) ->name('recordings.transcription-status'); }); diff --git a/tests/Feature/DiskSpaceTest.php b/tests/Feature/DiskSpaceTest.php index 3a6e95e..fe21a59 100644 --- a/tests/Feature/DiskSpaceTest.php +++ b/tests/Feature/DiskSpaceTest.php @@ -52,7 +52,7 @@ class DiskSpaceTest extends TestCase $this->get(route('recordings.index')) ->assertOk() - ->assertSee('Disk space') + ->assertSee('Disk space used', false) ->assertSee('free') ->assertSee('role="progressbar"', false); } diff --git a/tests/Feature/RecordingAudioStreamTest.php b/tests/Feature/RecordingAudioStreamTest.php new file mode 100644 index 0000000..d4cf479 --- /dev/null +++ b/tests/Feature/RecordingAudioStreamTest.php @@ -0,0 +1,115 @@ +put('recordings/note.mp3', 'fake-audio-bytes'); + + $user = User::factory()->create(); + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Note', + 'original_filename' => 'note.mp3', + 'file_path' => 'recordings/note.mp3', + 'file_size_bytes' => 16, + 'transcription_status' => 'pending', + ]); + + $this->actingAs($user) + ->get(route('recordings.audio', $recording)) + ->assertOk() + ->assertHeader('content-type', 'audio/mpeg') + ->assertHeader('content-disposition', 'inline; filename=note.mp3'); + } + + public function test_show_page_includes_audio_player(): void + { + $user = User::factory()->create(); + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Playable', + 'original_filename' => 'playable.mp3', + 'file_path' => 'recordings/playable.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'pending', + ]); + + $this->actingAs($user) + ->get(route('recordings.show', $recording)) + ->assertOk() + ->assertSee('Play', false) + ->assertSee(route('recordings.audio', $recording), false) + ->assertSee('create(); + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'List playable', + 'original_filename' => 'list.mp3', + 'file_path' => 'recordings/list.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'pending', + ]); + + $this->actingAs($user) + ->get(route('recordings.index')) + ->assertOk() + ->assertSee('List playable') + ->assertSee(route('recordings.show', $recording), false); + } + + public function test_other_user_cannot_stream_recording_audio(): void + { + Storage::fake('local'); + Storage::disk('local')->put('recordings/secret.mp3', 'fake-audio-bytes'); + + $owner = User::factory()->create(); + $intruder = User::factory()->create(); + $recording = Recording::query()->create([ + 'user_id' => $owner->id, + 'title' => 'Secret', + 'original_filename' => 'secret.mp3', + 'file_path' => 'recordings/secret.mp3', + 'file_size_bytes' => 16, + 'transcription_status' => 'pending', + ]); + + $this->actingAs($intruder) + ->get(route('recordings.audio', $recording)) + ->assertForbidden(); + } + + public function test_missing_audio_file_returns_not_found(): void + { + Storage::fake('local'); + + $user = User::factory()->create(); + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Gone', + 'original_filename' => 'gone.mp3', + 'file_path' => 'recordings/gone.mp3', + 'file_size_bytes' => 10, + 'transcription_status' => 'pending', + ]); + + $this->actingAs($user) + ->get(route('recordings.audio', $recording)) + ->assertNotFound(); + } +} diff --git a/tests/Feature/RecordingDuplicateUploadTest.php b/tests/Feature/RecordingDuplicateUploadTest.php index a2a5224..339dccb 100644 --- a/tests/Feature/RecordingDuplicateUploadTest.php +++ b/tests/Feature/RecordingDuplicateUploadTest.php @@ -3,12 +3,14 @@ namespace Tests\Feature; use App\Jobs\TranscribeRecording; +use App\Livewire\UploadRecordings; use App\Models\Recording; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; +use Livewire\Livewire; use Tests\TestCase; class RecordingDuplicateUploadTest extends TestCase @@ -33,19 +35,18 @@ class RecordingDuplicateUploadTest extends TestCase $first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes'); $duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes'); - $this->post(route('recordings.store'), [ - 'audio' => [$first], - ])->assertRedirect(); + Livewire::test(UploadRecordings::class) + ->set('audio', [$first]) + ->assertRedirect(); $this->assertSame(1, Recording::query()->count()); Bus::assertDispatched(TranscribeRecording::class, 1); - $response = $this->post(route('recordings.store'), [ - 'audio' => [$duplicate], - ]); + Livewire::test(UploadRecordings::class) + ->set('audio', [$duplicate]) + ->assertRedirect(route('recordings.create')) + ->assertSessionHas('error'); - $response->assertRedirect(route('recordings.create')); - $response->assertSessionHas('error'); $this->assertSame(1, Recording::query()->count()); Bus::assertDispatched(TranscribeRecording::class, 1); } @@ -59,18 +60,17 @@ class RecordingDuplicateUploadTest extends TestCase $two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes'); $three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes'); - $response = $this->post(route('recordings.store'), [ - 'audio' => [$one, $two, $three], - ]); + Livewire::test(UploadRecordings::class) + ->set('audio', [$one, $two, $three]) + ->assertRedirect(route('recordings.index')) + ->assertSessionHas('success'); - $response->assertRedirect(route('recordings.index')); - $response->assertSessionHas('success'); $this->assertStringContainsString('Skipped 1 duplicate', session('success')); $this->assertSame(2, Recording::query()->count()); Bus::assertDispatched(TranscribeRecording::class, 2); } - public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void + public function test_upload_page_mentions_duplicate_skipping(): void { Recording::query()->create([ 'user_id' => $this->user->id, @@ -84,7 +84,7 @@ class RecordingDuplicateUploadTest extends TestCase $this->get(route('recordings.create')) ->assertOk() - ->assertSee('note.mp3:2048', false) + ->assertSeeLivewire('upload-recordings') ->assertSee('Duplicate files', false); } @@ -103,12 +103,11 @@ class RecordingDuplicateUploadTest extends TestCase 'transcription_status' => 'done', ]); - $response = $this->post(route('recordings.store'), [ - 'audio' => [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')], - ]); + Livewire::test(UploadRecordings::class) + ->set('audio', [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')]) + ->assertRedirect(route('recordings.create')) + ->assertSessionHas('error'); - $response->assertRedirect(route('recordings.create')); - $response->assertSessionHas('error'); $this->assertSame(1, Recording::query()->count()); Bus::assertNothingDispatched(); } @@ -120,9 +119,9 @@ class RecordingDuplicateUploadTest extends TestCase $file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash'); - $this->post(route('recordings.store'), [ - 'audio' => [$file], - ])->assertRedirect(); + Livewire::test(UploadRecordings::class) + ->set('audio', [$file]) + ->assertRedirect(); $recording = Recording::query()->first(); $this->assertNotNull($recording); diff --git a/tests/Feature/RecordingOwnershipTest.php b/tests/Feature/RecordingOwnershipTest.php index 9a539e2..b44d7fc 100644 --- a/tests/Feature/RecordingOwnershipTest.php +++ b/tests/Feature/RecordingOwnershipTest.php @@ -2,9 +2,11 @@ namespace Tests\Feature; +use App\Livewire\Recordings\Show; use App\Models\Recording; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; use Tests\TestCase; class RecordingOwnershipTest extends TestCase @@ -74,8 +76,9 @@ class RecordingOwnershipTest extends TestCase 'transcription_status' => 'done', ]); - $this->actingAs($intruder) - ->delete(route('recordings.destroy', $recording)) + $this->actingAs($intruder); + + Livewire::test(Show::class, ['recording' => $recording]) ->assertForbidden(); $this->assertDatabaseHas('recordings', ['id' => $recording->id]); diff --git a/tests/Feature/RecordingUploadTest.php b/tests/Feature/RecordingUploadTest.php index 1a94733..c7b28ec 100644 --- a/tests/Feature/RecordingUploadTest.php +++ b/tests/Feature/RecordingUploadTest.php @@ -4,6 +4,9 @@ namespace Tests\Feature; use App\Http\Requests\StoreRecordingRequest; use App\Jobs\TranscribeRecording; +use App\Livewire\Recordings\Index; +use App\Livewire\Recordings\Show; +use App\Livewire\UploadRecordings; use App\Models\Recording; use App\Models\User; use App\Services\TranscriptionService; @@ -12,6 +15,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; use Laravel\Ai\Transcription; +use Livewire\Livewire; use Tests\TestCase; class RecordingUploadTest extends TestCase @@ -54,15 +58,14 @@ class RecordingUploadTest extends TestCase $file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg'); - $response = $this->post(route('recordings.store'), [ - 'audio' => [$file], - 'title' => 'Team meeting', - ]); + Livewire::test(UploadRecordings::class) + ->set('title', 'Team meeting') + ->set('audio', [$file]) + ->assertRedirect(route('recordings.show', Recording::query()->first())); $recording = Recording::query()->first(); $this->assertNotNull($recording); - $response->assertRedirect(route('recordings.show', $recording)); $this->assertSame('Team meeting', $recording->title); $this->assertSame('pending', $recording->transcription_status); $this->assertSame('local', $recording->transcription_driver); @@ -76,16 +79,13 @@ class RecordingUploadTest extends TestCase Storage::fake('local'); Bus::fake(); - $response = $this->post(route('recordings.store'), [ - 'audio' => [ + Livewire::test(UploadRecordings::class) + ->set('audio', [ UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)), UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)), UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)), - ], - ]); - - $response->assertRedirect(route('recordings.index')); - $response->assertSessionHas('success'); + ]) + ->assertRedirect(route('recordings.index')); $this->assertSame(3, Recording::query()->count()); Bus::assertDispatched(TranscribeRecording::class, 3); @@ -99,9 +99,9 @@ class RecordingUploadTest extends TestCase { $this->get(route('recordings.create')) ->assertOk() - ->assertSee('Drop audio files here') + ->assertSee('Drop audio files here or click to browse') ->assertSee('max 2 GB each', false) - ->assertSee('name="audio[]"', false); + ->assertSeeLivewire('upload-recordings'); } public function test_files_larger_than_two_gigabytes_are_rejected(): void @@ -113,11 +113,9 @@ class RecordingUploadTest extends TestCase ->create('huge.mp3', 10, 'audio/mpeg') ->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1); - $this->from(route('recordings.create')) - ->post(route('recordings.store'), [ - 'audio' => [$file], - ]) - ->assertSessionHasErrors(['audio.0']); + Livewire::test(UploadRecordings::class) + ->set('audio', [$file]) + ->assertHasErrors(['audio.0']); $this->assertSame(0, Recording::query()->count()); Bus::assertNothingDispatched(); @@ -133,15 +131,14 @@ class RecordingUploadTest extends TestCase ['clip.ogg', 'audio/ogg', 'ogg-bytes'], ['talk.m4a', 'audio/mp4', 'm4a-bytes'], ] as [$name, $mime, $contents]) { - $response = $this->post(route('recordings.store'), [ - 'audio' => [UploadedFile::fake()->createWithContent($name, $contents)], - 'title' => $name, - ]); + Livewire::test(UploadRecordings::class) + ->set('title', $name) + ->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)]) + ->assertRedirect(); $recording = Recording::query()->where('title', $name)->first(); $this->assertNotNull($recording, "Failed uploading {$name}"); - $response->assertRedirect(route('recordings.show', $recording)); Storage::disk('local')->assertExists($recording->file_path); } @@ -152,11 +149,9 @@ class RecordingUploadTest extends TestCase { Storage::fake('local'); - $this->from(route('recordings.create')) - ->post(route('recordings.store'), [ - 'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')], - ]) - ->assertSessionHasErrors(['audio.0']); + Livewire::test(UploadRecordings::class) + ->set('audio', [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')]) + ->assertHasErrors(['audio.0']); } public function test_user_can_queue_local_transcription(): void @@ -173,8 +168,8 @@ class RecordingUploadTest extends TestCase // Fake AI so the afterResponse job (sync) does not call a real provider. Transcription::fake(['Queued transcription text.']); - $this->post(route('recordings.transcribe', $recording)) - ->assertRedirect(); + Livewire::test(Show::class, ['recording' => $recording]) + ->call('startTranscription'); $recording->refresh(); $this->assertSame('local', $recording->transcription_driver); @@ -332,8 +327,8 @@ class RecordingUploadTest extends TestCase 'updated_at' => now()->subMinutes(10), ]); - $this->post(route('recordings.transcribe', $recording)) - ->assertRedirect(); + Livewire::test(Show::class, ['recording' => $recording]) + ->call('startTranscription'); $recording->refresh(); $this->assertSame('local', $recording->transcription_driver); @@ -355,9 +350,8 @@ class RecordingUploadTest extends TestCase 'transcription_started_at' => now(), ]); - $this->post(route('recordings.transcribe.cancel', $recording)) - ->assertRedirect() - ->assertSessionHas('success'); + Livewire::test(Show::class, ['recording' => $recording]) + ->call('cancelTranscription'); $recording->refresh(); $this->assertSame('cancelled', $recording->transcription_status); @@ -380,9 +374,8 @@ class RecordingUploadTest extends TestCase 'transcription_started_at' => now()->subMinute(), ]); - $this->post(route('recordings.transcribe', $recording)) - ->assertRedirect() - ->assertSessionHas('success'); + Livewire::test(Show::class, ['recording' => $recording]) + ->call('startTranscription'); $recording->refresh(); $this->assertSame('local', $recording->transcription_driver); @@ -464,8 +457,8 @@ class RecordingUploadTest extends TestCase 'transcribed_at' => now()->subHour(), ]); - $this->post(route('recordings.transcribe', $recording)) - ->assertRedirect(); + Livewire::test(Show::class, ['recording' => $recording]) + ->call('startTranscription'); $recording->refresh(); $this->assertSame('Old transcript text.', $recording->transcript); @@ -497,10 +490,8 @@ class RecordingUploadTest extends TestCase 'transcript' => 'Finished text', ]); - $this->from(route('recordings.index')) - ->post(route('recordings.transcribe-pending')) - ->assertRedirect(route('recordings.index')) - ->assertSessionHas('success'); + Livewire::test(Index::class) + ->call('queuePending'); Bus::assertDispatched(TranscribeRecording::class, 1); } diff --git a/tests/Feature/Recordings/IndexTest.php b/tests/Feature/Recordings/IndexTest.php new file mode 100644 index 0000000..c656b53 --- /dev/null +++ b/tests/Feature/Recordings/IndexTest.php @@ -0,0 +1,90 @@ +create(); + + Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Pocket note', + 'original_filename' => 'note.mp3', + 'file_path' => 'recordings/note.mp3', + 'file_size_bytes' => 1024, + 'transcription_status' => 'done', + 'transcript' => 'hello world', + ]); + + $this->actingAs($user) + ->get(route('recordings.index')) + ->assertOk() + ->assertSeeLivewire(Index::class) + ->assertSee('Pocket note'); + } + + public function test_search_filters_recordings(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + + Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Office chat', + 'original_filename' => 'office.mp3', + 'file_path' => 'recordings/office.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'transcript' => 'talking about the pocket recorder today', + ]); + + Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Unrelated', + 'original_filename' => 'other.mp3', + 'file_path' => 'recordings/other.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'transcript' => 'nothing useful', + ]); + + Livewire::test(Index::class) + ->set('search', 'pocket recorder') + ->assertSee('Office chat') + ->assertDontSee('Unrelated'); + } + + public function test_queue_pending_dispatches_jobs(): void + { + Bus::fake(); + $user = User::factory()->create(); + $this->actingAs($user); + + Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Needs work', + 'original_filename' => 'needs.mp3', + 'file_path' => 'recordings/needs.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'pending', + ]); + + Livewire::test(Index::class) + ->call('queuePending'); + + Bus::assertDispatched(TranscribeRecording::class, 1); + } +} diff --git a/tests/Feature/Recordings/ShowTest.php b/tests/Feature/Recordings/ShowTest.php new file mode 100644 index 0000000..2eb1986 --- /dev/null +++ b/tests/Feature/Recordings/ShowTest.php @@ -0,0 +1,62 @@ +create(); + + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Show me', + 'original_filename' => 'show.mp3', + 'file_path' => 'recordings/show.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'transcript' => 'Finished text', + ]); + + $this->actingAs($user) + ->get(route('recordings.show', $recording)) + ->assertOk() + ->assertSeeLivewire(Show::class) + ->assertSee('Show me') + ->assertSee('Play', false); + } + + public function test_user_can_delete_own_recording(): void + { + Storage::fake('local'); + Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes'); + + $user = User::factory()->create(); + $this->actingAs($user); + + $recording = Recording::query()->create([ + 'user_id' => $user->id, + 'title' => 'Delete me', + 'original_filename' => 'delete-me.mp3', + 'file_path' => 'recordings/delete-me.mp3', + 'file_size_bytes' => 5, + 'transcription_status' => 'done', + ]); + + Livewire::test(Show::class, ['recording' => $recording]) + ->call('delete') + ->assertRedirect(route('recordings.index')); + + $this->assertDatabaseMissing('recordings', ['id' => $recording->id]); + } +} diff --git a/tests/Feature/UploadRecordingsLivewireTest.php b/tests/Feature/UploadRecordingsLivewireTest.php new file mode 100644 index 0000000..610f87f --- /dev/null +++ b/tests/Feature/UploadRecordingsLivewireTest.php @@ -0,0 +1,66 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + } + + public function test_dropping_files_saves_and_queues_transcription(): void + { + Storage::fake('local'); + Bus::fake(); + + $file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg'); + + Livewire::test(UploadRecordings::class) + ->set('title', 'Team meeting') + ->set('audio', [$file]) + ->assertRedirect(route('recordings.show', Recording::query()->first())); + + $recording = Recording::query()->first(); + + $this->assertNotNull($recording); + $this->assertSame('Team meeting', $recording->title); + $this->assertSame('pending', $recording->transcription_status); + Storage::disk('local')->assertExists($recording->file_path); + Bus::assertDispatched(TranscribeRecording::class); + } + + public function test_batch_upload_redirects_to_index(): void + { + Storage::fake('local'); + Bus::fake(); + + Livewire::test(UploadRecordings::class) + ->set('audio', [ + UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)), + UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)), + ]) + ->assertRedirect(route('recordings.index')); + + $this->assertSame(2, Recording::query()->count()); + Bus::assertDispatched(TranscribeRecording::class, 2); + } +}