Fix stuck transcriptions and make transcripts stoppable and searchable.

Raise queue retry_after above the job timeout, recover orphaned runs, allow stop/restart with any engine, and keep finished transcripts searchable in the recordings list.
This commit is contained in:
ben
2026-08-12 15:10:33 +02:00
parent eb0f019025
commit bc6cb5efa4
14 changed files with 626 additions and 64 deletions
+181
View File
@@ -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);
}
}