diff --git a/.env.example b/.env.example index be14567..11ebab2 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,8 @@ LOCAL_WHISPER_API_KEY=not-needed LOCAL_WHISPER_MODEL=Systran/faster-whisper-base REMOTE_WHISPER_MODEL=Systran/faster-whisper-base TRANSCRIPTION_TIMEOUT=600 +# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run +DB_QUEUE_RETRY_AFTER=660 # Host port for docker compose whisper service WHISPER_HOST_PORT=8090 diff --git a/README.md b/README.md index d771c0d..00621da 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,11 @@ Copy values from `.env.example`. The transcription-related settings are: | `REMOTE_WHISPER_MODEL` | Model name for Ollama-host 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). + Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`). ## Running locally diff --git a/app/Http/Controllers/CancelTranscriptionController.php b/app/Http/Controllers/CancelTranscriptionController.php new file mode 100644 index 0000000..5bfd55e --- /dev/null +++ b/app/Http/Controllers/CancelTranscriptionController.php @@ -0,0 +1,23 @@ +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 index 95f08de..68b2b1e 100644 --- a/app/Http/Controllers/RecordingController.php +++ b/app/Http/Controllers/RecordingController.php @@ -17,21 +17,23 @@ class RecordingController extends Controller */ public function index(Request $request): View { + Recording::query() + ->whereIn('transcription_status', ['pending', 'processing']) + ->orderBy('id') + ->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription()); + $query = Recording::query()->latest(); if ($search = $request->string('q')->trim()->toString()) { - $query->where(function ($builder) use ($search) { - $builder->where('title', 'like', "%{$search}%") - ->orWhere('artist', 'like', "%{$search}%") - ->orWhere('album', 'like', "%{$search}%") - ->orWhere('original_filename', 'like', "%{$search}%") - ->orWhere('transcript', 'like', "%{$search}%"); - }); + $query->search($search); } $recordings = $query->paginate(20)->withQueryString(); - return view('recordings.index', compact('recordings', 'search')); + return view('recordings.index', [ + 'recordings' => $recordings, + 'search' => $search ?? '', + ]); } /** @@ -77,6 +79,9 @@ class RecordingController extends Controller */ public function show(Recording $recording): View { + $recording->recoverOrphanedTranscription(); + $recording->refresh(); + return view('recordings.show', compact('recording')); } diff --git a/app/Http/Controllers/TranscribeController.php b/app/Http/Controllers/TranscribeController.php index 037b1f0..d6a4ba4 100644 --- a/app/Http/Controllers/TranscribeController.php +++ b/app/Http/Controllers/TranscribeController.php @@ -11,11 +11,14 @@ class TranscribeController extends Controller { /** * Queue transcription for the recording with the chosen engine. + * + * Always allowed: stops any current run first, then starts the new engine. */ public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse { - if ($recording->transcription_status === 'processing') { - return back()->with('error', 'Transcription is already in progress.'); + if ($recording->isTranscribing() || $recording->hasActiveTranscriptionJob()) { + $recording->cancelTranscription(silent: true); + $recording->refresh(); } $driver = $request->validated('driver'); @@ -28,8 +31,8 @@ class TranscribeController extends Controller 'transcription_percent' => 5, 'transcription_started_at' => now(), 'transcription_error' => null, - 'transcript' => null, - 'transcribed_at' => null, + // Keep the previous transcript until a new run succeeds. + 'transcribed_at' => $recording->transcribed_at, ]); TranscribeRecording::dispatch($recording->fresh()); diff --git a/app/Http/Controllers/TranscriptionStatusController.php b/app/Http/Controllers/TranscriptionStatusController.php index f7daa69..449d9c1 100644 --- a/app/Http/Controllers/TranscriptionStatusController.php +++ b/app/Http/Controllers/TranscriptionStatusController.php @@ -12,6 +12,12 @@ class TranscriptionStatusController extends Controller */ public function __invoke(Recording $recording): JsonResponse { - return response()->json($recording->fresh()->transcriptionStatusPayload()); + $recording = $recording->fresh(); + + if ($recording->recoverOrphanedTranscription()) { + $recording->refresh(); + } + + return response()->json($recording->transcriptionStatusPayload()); } } diff --git a/app/Jobs/TranscribeRecording.php b/app/Jobs/TranscribeRecording.php index 4af9e8f..6735be6 100644 --- a/app/Jobs/TranscribeRecording.php +++ b/app/Jobs/TranscribeRecording.php @@ -13,17 +13,28 @@ class TranscribeRecording implements ShouldQueue { use Queueable; + /** + * The number of times the job may be attempted. + */ + public int $tries = 1; + /** * The number of seconds the job can run before timing out. */ - public int $timeout = 600; + public int $timeout; + + /** + * ISO-8601 transcription_started_at this job owns (ignored after cancel/restart). + */ + public ?string $runStartedAt = null; /** * Create a new job instance. */ public function __construct(public Recording $recording) { - // + $this->timeout = max(60, (int) config('ai.transcription_timeout', 600)); + $this->runStartedAt = $recording->transcription_started_at?->toIso8601String(); } /** @@ -31,7 +42,21 @@ class TranscribeRecording implements ShouldQueue */ public function handle(TranscriptionService $transcription): void { - $this->recording->refresh(); + $recording = Recording::query()->find($this->recording->id); + + if ($recording === null) { + return; + } + + $this->recording = $recording; + + if ($this->runStartedAt !== null && ! $this->recording->ownsTranscriptionRun($this->runStartedAt)) { + return; + } + + if ($this->runStartedAt === null && ! $this->recording->isTranscribing()) { + return; + } $this->recording->forceFill([ 'transcription_status' => 'processing', @@ -39,15 +64,27 @@ class TranscribeRecording implements ShouldQueue 'transcription_error' => null, ])->save(); - $this->recording->reportProgress('Preparing audio file…', 15); + $this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String(); + + $this->reportIfOwned('Preparing audio file…', 15); try { $text = $transcription->transcribe( $this->recording, - fn (string $message, int $percent) => $this->recording->reportProgress($message, $percent), + function (string $message, int $percent): void { + $this->reportIfOwned($message, $percent); + }, ); - $this->recording->reportProgress('Saving transcript…', 90); + if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) { + return; + } + + $this->reportIfOwned('Saving transcript…', 90); + + if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) { + return; + } $this->recording->forceFill([ 'transcript' => $text, @@ -58,18 +95,17 @@ class TranscribeRecording implements ShouldQueue 'transcribed_at' => now(), ])->save(); } catch (Throwable $e) { + if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) { + return; + } + Log::error('Transcription failed', [ 'recording_id' => $this->recording->id, 'driver' => $this->recording->transcription_driver, 'message' => $e->getMessage(), ]); - $this->recording->forceFill([ - 'transcription_status' => 'failed', - 'transcription_progress' => 'Transcription failed', - 'transcription_percent' => $this->recording->transcription_percent ?: 0, - 'transcription_error' => $e->getMessage(), - ])->save(); + $this->recording->markTranscriptionFailed($e->getMessage()); throw $e; } @@ -80,10 +116,23 @@ class TranscribeRecording implements ShouldQueue */ public function failed(?Throwable $e): void { - $this->recording->forceFill([ - 'transcription_status' => 'failed', - 'transcription_progress' => 'Transcription failed', - 'transcription_error' => $e?->getMessage() ?: 'Transcription stopped unexpectedly.', - ])->save(); + $recording = Recording::query()->find($this->recording->id); + + if ($recording === null || ! $recording->ownsTranscriptionRun($this->runStartedAt)) { + return; + } + + $recording->markTranscriptionFailed( + $e?->getMessage() ?: 'Transcription stopped unexpectedly.', + ); + } + + private function reportIfOwned(string $message, int $percent): void + { + if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) { + return; + } + + $this->recording->reportProgress($message, $percent); } } diff --git a/app/Models/Recording.php b/app/Models/Recording.php index 0c42756..ae949f6 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -2,9 +2,17 @@ namespace App\Models; +use App\Jobs\TranscribeRecording; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Throwable; class Recording extends Model { @@ -86,6 +94,233 @@ class Recording extends Model return in_array($this->transcription_status, ['pending', 'processing'], true); } + /** + * Search title, metadata, and stored transcript text. + */ + #[Scope] + protected function search(Builder $query, string $term): void + { + $like = '%'.$term.'%'; + + $query->where(function (Builder $builder) use ($like): void { + $builder->where('title', 'like', $like) + ->orWhere('artist', 'like', $like) + ->orWhere('album', 'like', $like) + ->orWhere('original_filename', 'like', $like) + ->orWhere('transcript', 'like', $like); + }); + } + + /** + * Short transcript excerpt, optionally centered on a search hit. + */ + public function transcriptSnippet(?string $term = null, int $radius = 80): ?string + { + if (! filled($this->transcript)) { + return null; + } + + $transcript = preg_replace('/\s+/', ' ', $this->transcript) ?? $this->transcript; + + if ($term === null || $term === '') { + return Str::limit($transcript, $radius * 2); + } + + $position = mb_stripos($transcript, $term); + + if ($position === false) { + return Str::limit($transcript, $radius * 2); + } + + $start = max(0, $position - $radius); + $excerpt = mb_substr($transcript, $start, ($radius * 2) + mb_strlen($term)); + + return ($start > 0 ? '…' : '').$excerpt.(mb_strlen($transcript) > $start + mb_strlen($excerpt) ? '…' : ''); + } + + /** + * Whether a TranscribeRecording job for this recording is still on the queue. + */ + public function hasActiveTranscriptionJob(): bool + { + return DB::table('jobs') + ->pluck('payload') + ->contains(function (string $payload): bool { + if (! str_contains($payload, TranscribeRecording::class)) { + return false; + } + + $data = json_decode($payload, true); + $command = $data['data']['command'] ?? null; + + if (! is_string($command)) { + return false; + } + + try { + $job = unserialize($command); + } catch (Throwable) { + return (bool) preg_match('/id";i:'.$this->id.';/', $payload); + } + + return $job instanceof TranscribeRecording + && (int) $job->recording->getKey() === (int) $this->id; + }); + } + + /** + * Processing/pending with no worker job left (crashed worker, bad retry_after, etc.). + */ + public function isOrphanedTranscription(): bool + { + if (! $this->isTranscribing()) { + return false; + } + + if ($this->hasActiveTranscriptionJob()) { + return false; + } + + $reference = $this->transcription_started_at ?? $this->updated_at; + + // Allow a short window after dispatch before the row appears / worker claims it. + if ($reference !== null && $reference->gt(now()->subSeconds(15))) { + return false; + } + + return true; + } + + /** + * Whether this recording still expects results for the given run. + */ + public function ownsTranscriptionRun(?string $runStartedAt): bool + { + $this->refresh(); + + if (! $this->isTranscribing()) { + return false; + } + + if ($runStartedAt === null || $this->transcription_started_at === null) { + return false; + } + + return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp(); + } + + /** + * Remove queued TranscribeRecording jobs for this recording. + */ + public function discardQueuedTranscriptionJobs(): int + { + $deleted = 0; + + DB::table('jobs') + ->orderBy('id') + ->get() + ->each(function (object $job) use (&$deleted): void { + $payload = (string) $job->payload; + + if (! str_contains($payload, TranscribeRecording::class)) { + return; + } + + $data = json_decode($payload, true); + $command = $data['data']['command'] ?? null; + + if (! is_string($command)) { + return; + } + + try { + $queued = unserialize($command); + } catch (Throwable) { + if (! preg_match('/id";i:'.$this->id.';/', $payload)) { + return; + } + + DB::table('jobs')->where('id', $job->id)->delete(); + $deleted++; + + return; + } + + if ($queued instanceof TranscribeRecording && (int) $queued->recording->getKey() === (int) $this->id) { + DB::table('jobs')->where('id', $job->id)->delete(); + $deleted++; + } + }); + + $this->releaseTranscriptionUniqueLock(); + + return $deleted; + } + + /** + * Stop transcription: drop queued jobs and mark the run cancelled. + * + * @param bool $silent When true, skip status update (used before starting a replacement run). + */ + public function cancelTranscription(bool $silent = false): void + { + $this->discardQueuedTranscriptionJobs(); + + if ($silent) { + return; + } + + $this->forceFill([ + 'transcription_status' => 'cancelled', + 'transcription_progress' => 'Stopped by user', + 'transcription_percent' => $this->transcription_percent ?: 0, + 'transcription_error' => 'Stopped by user', + ])->save(); + } + + /** + * Release a leftover ShouldBeUnique lock from earlier job versions. + */ + public function releaseTranscriptionUniqueLock(): void + { + Cache::lock( + 'laravel_unique_job:'.TranscribeRecording::class.'transcribe-recording:'.$this->id + )->forceRelease(); + } + + /** + * Mark transcription as failed and unblock the UI. + */ + public function markTranscriptionFailed(string $message): void + { + if (! $this->isTranscribing()) { + return; + } + + $this->forceFill([ + 'transcription_status' => 'failed', + 'transcription_progress' => 'Transcription failed', + 'transcription_percent' => $this->transcription_percent ?: 0, + 'transcription_error' => $message, + ])->save(); + } + + /** + * Recover a stuck transcription if the queue job is gone. + */ + public function recoverOrphanedTranscription(): bool + { + if (! $this->isOrphanedTranscription()) { + return false; + } + + $this->markTranscriptionFailed( + 'Transcription worker stopped before finishing. Start transcription again.', + ); + + return true; + } + /** * Update the live progress fields shown in the UI. */ @@ -141,6 +376,7 @@ class Recording extends Model 'duration_seconds' => $this->duration_seconds, 'is_active' => $this->isTranscribing(), 'has_transcript' => filled($this->transcript), + 'transcript' => $this->transcript, 'transcribed_at' => $this->transcribed_at?->toIso8601String(), ]; } diff --git a/config/queue.php b/config/queue.php index 79c2c0a..36b1c89 100644 --- a/config/queue.php +++ b/config/queue.php @@ -40,7 +40,8 @@ return [ 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), - 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + // Must exceed TranscribeRecording::$timeout / TRANSCRIPTION_TIMEOUT (default 600). + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 660), 'after_commit' => false, ], diff --git a/resources/views/recordings/index.blade.php b/resources/views/recordings/index.blade.php index f1bf70c..11ee11f 100644 --- a/resources/views/recordings/index.blade.php +++ b/resources/views/recordings/index.blade.php @@ -51,6 +51,11 @@ @if ($recording->artist)
{{ $recording->artist }}
@endif + @if ($snippet = $recording->transcriptSnippet($search ?: null)) +

+ {{ $snippet }} +

+ @endif {{ $recording->duration_formatted }} diff --git a/resources/views/recordings/partials/status-badge.blade.php b/resources/views/recordings/partials/status-badge.blade.php index 4093800..ebd5ba5 100644 --- a/resources/views/recordings/partials/status-badge.blade.php +++ b/resources/views/recordings/partials/status-badge.blade.php @@ -2,7 +2,9 @@ $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', + 'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20', 'failed' => 'bg-red-50 text-red-800 ring-red-600/20', + 'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20', default => 'bg-stone-100 text-stone-700 ring-stone-500/20', }; @endphp diff --git a/resources/views/recordings/show.blade.php b/resources/views/recordings/show.blade.php index 750db91..db05a84 100644 --- a/resources/views/recordings/show.blade.php +++ b/resources/views/recordings/show.blade.php @@ -78,13 +78,13 @@

Transcribe

-

Choose how to convert this recording to text.

+

Choose an engine anytime — starting a new run stops the current one.

@csrf
@@ -136,16 +152,15 @@

Transcript

- @if ($recording->transcript) - - @endif +
@@ -181,26 +196,31 @@
+
+ Transcription stopped. Choose an engine above to start again. +
+

Transcription failed

- @if ($recording->transcript) -
-

{{ $recording->transcript }}

- @if ($recording->transcribed_at) -

Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}

- @endif -
- @else +
+

- No transcript yet. Choose an engine above to start. -

- @endif + class="mt-4 text-xs text-stone-500" + x-show="status.transcribed_at" + x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''" + >

+
+ +

+ No transcript yet. Choose an engine above to start. +

@@ -222,11 +242,20 @@ processing: 'bg-amber-50 text-amber-800 ring-amber-600/20', pending: 'bg-amber-50 text-amber-800 ring-amber-600/20', failed: 'bg-red-50 text-red-800 ring-red-600/20', + cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20', }; return map[this.status.status] || 'bg-stone-100 text-stone-700 ring-stone-500/20'; }, + get startButtonLabel() { + if (this.status.is_active) { + return 'Switch engine / restart'; + } + + return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription'; + }, + start() { if (this.status.is_active) { this.beginPolling(); @@ -273,6 +302,9 @@ if (wasActive && !this.status.is_active) { this.stopPolling(); + if (this.status.has_transcript || this.status.status === 'failed' || this.status.status === 'cancelled') { + return; + } window.location.reload(); } } catch (error) { @@ -292,6 +324,17 @@ const remain = seconds % 60; return minutes + ':' + String(remain).padStart(2, '0'); }, + + formatTimestamp(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const pad = (n) => String(n).padStart(2, '0'); + return date.getFullYear() + + '-' + pad(date.getMonth() + 1) + + '-' + pad(date.getDate()) + + ' ' + pad(date.getHours()) + + ':' + pad(date.getMinutes()); + }, }; } diff --git a/routes/web.php b/routes/web.php index a487dd8..6805566 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ except(['edit', 'update']); 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/{recording}/transcription-status', TranscriptionStatusController::class) ->name('recordings.transcription-status'); diff --git a/tests/Feature/RecordingUploadTest.php b/tests/Feature/RecordingUploadTest.php index ff3a085..a7be7a4 100644 --- a/tests/Feature/RecordingUploadTest.php +++ b/tests/Feature/RecordingUploadTest.php @@ -7,6 +7,7 @@ use App\Models\Recording; use App\Services\TranscriptionService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; use Laravel\Ai\Transcription; use Tests\TestCase; @@ -202,4 +203,184 @@ class RecordingUploadTest extends TestCase $this->assertSame('failed', $recording->transcription_status); $this->assertSame('Provider unavailable', $recording->transcription_error); } + + public function test_orphaned_processing_is_recovered_on_status_poll(): void + { + $recording = Recording::query()->create([ + 'title' => 'Stuck', + 'original_filename' => 'stuck.mp3', + 'file_path' => 'recordings/stuck.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'processing', + 'transcription_progress' => 'Transcribing locally…', + 'transcription_percent' => 50, + 'transcription_driver' => 'local', + 'transcription_started_at' => now()->subMinutes(5), + 'updated_at' => now()->subMinutes(5), + ]); + + $this->getJson(route('recordings.transcription-status', $recording)) + ->assertOk() + ->assertJsonPath('status', 'failed') + ->assertJsonPath('is_active', false); + + $recording->refresh(); + $this->assertSame('failed', $recording->transcription_status); + $this->assertStringContainsString('worker stopped', $recording->transcription_error); + } + + public function test_orphaned_processing_can_be_restarted(): void + { + Transcription::fake(['Recovered transcript.']); + + $recording = Recording::query()->create([ + 'title' => 'Restart me', + 'original_filename' => 'restart.mp3', + 'file_path' => 'recordings/restart.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'processing', + 'transcription_driver' => 'local', + 'transcription_started_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + $this->post(route('recordings.transcribe', $recording), [ + 'driver' => 'cloud', + ])->assertRedirect(); + + $recording->refresh(); + $this->assertSame('cloud', $recording->transcription_driver); + $this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']); + } + + public function test_user_can_stop_an_active_transcription(): void + { + $recording = Recording::query()->create([ + 'title' => 'Stop me', + 'original_filename' => 'stop.mp3', + 'file_path' => 'recordings/stop.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'processing', + 'transcription_progress' => 'Transcribing…', + 'transcription_percent' => 40, + 'transcription_driver' => 'local', + 'transcription_started_at' => now(), + ]); + + $this->post(route('recordings.transcribe.cancel', $recording)) + ->assertRedirect() + ->assertSessionHas('success'); + + $recording->refresh(); + $this->assertSame('cancelled', $recording->transcription_status); + $this->assertSame('Stopped by user', $recording->transcription_error); + $this->assertFalse($recording->isTranscribing()); + } + + public function test_user_can_start_a_different_engine_while_processing(): void + { + Transcription::fake(['Switched engine transcript.']); + + $recording = Recording::query()->create([ + 'title' => 'Switch me', + 'original_filename' => 'switch.mp3', + 'file_path' => 'recordings/switch.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'processing', + 'transcription_driver' => 'local', + 'transcription_started_at' => now()->subMinute(), + ]); + + $this->post(route('recordings.transcribe', $recording), [ + 'driver' => 'cloud', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + $recording->refresh(); + $this->assertSame('cloud', $recording->transcription_driver); + $this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']); + $this->assertNull($recording->transcription_error); + } + + public function test_cancelled_job_does_not_overwrite_status(): void + { + Storage::fake('local'); + Storage::disk('local')->put('recordings/ignore.mp3', 'fake-audio-bytes'); + + Transcription::fake(['Should be ignored.']); + + $recording = Recording::query()->create([ + 'title' => 'Ignore late job', + 'original_filename' => 'ignore.mp3', + 'file_path' => 'recordings/ignore.mp3', + 'file_size_bytes' => 12, + 'transcription_status' => 'processing', + 'transcription_driver' => 'cloud', + 'transcription_started_at' => now()->subMinute(), + 'transcript' => 'Previous transcript stays.', + ]); + + $job = new TranscribeRecording($recording); + $recording->cancelTranscription(); + + $job->handle(app(TranscriptionService::class)); + + $recording->refresh(); + $this->assertSame('cancelled', $recording->transcription_status); + $this->assertSame('Previous transcript stays.', $recording->transcript); + } + + public function test_recordings_can_be_searched_by_transcript(): void + { + Recording::query()->create([ + 'title' => 'Office chat', + 'original_filename' => 'office.mp3', + 'file_path' => 'recordings/office.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'transcript' => 'We should ship the pocket recorder summary feature next week.', + ]); + + Recording::query()->create([ + 'title' => 'Kitchen note', + 'original_filename' => 'kitchen.mp3', + 'file_path' => 'recordings/kitchen.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'transcript' => 'Buy milk and eggs.', + ]); + + $this->get(route('recordings.index', ['q' => 'pocket recorder'])) + ->assertOk() + ->assertSee('Office chat') + ->assertSee('pocket recorder') + ->assertDontSee('Kitchen note'); + } + + public function test_restarting_transcription_keeps_previous_transcript_until_success(): void + { + Bus::fake(); + + $recording = Recording::query()->create([ + 'title' => 'Keep me', + 'original_filename' => 'keep.mp3', + 'file_path' => 'recordings/keep.mp3', + 'file_size_bytes' => 100, + 'transcription_status' => 'done', + 'transcription_driver' => 'cloud', + 'transcript' => 'Old transcript text.', + 'transcribed_at' => now()->subHour(), + ]); + + $this->post(route('recordings.transcribe', $recording), [ + 'driver' => 'local', + ])->assertRedirect(); + + $recording->refresh(); + $this->assertSame('Old transcript text.', $recording->transcript); + $this->assertSame('local', $recording->transcription_driver); + $this->assertSame('pending', $recording->transcription_status); + Bus::assertDispatched(TranscribeRecording::class); + } }