*/ protected $fillable = [ 'user_id', 'title', 'original_filename', 'file_path', 'duration_seconds', 'recorded_at', 'artist', 'album', 'file_size_bytes', 'content_hash', 'transcript', 'transcription_status', 'transcription_progress', 'transcription_percent', 'transcription_started_at', 'transcription_error', 'transcription_driver', 'ollama_url', 'transcribed_at', ]; /** * @return array */ protected function casts(): array { return [ 'recorded_at' => 'datetime', 'transcribed_at' => 'datetime', 'transcription_started_at' => 'datetime', 'duration_seconds' => 'integer', 'file_size_bytes' => 'integer', 'transcription_percent' => 'integer', 'user_id' => 'integer', ]; } /** * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** * Human-readable duration (m:ss). */ protected function durationFormatted(): Attribute { return Attribute::get(function (): string { if ($this->duration_seconds === null) { return '—'; } $minutes = intdiv($this->duration_seconds, 60); $seconds = $this->duration_seconds % 60; return sprintf('%d:%02d', $minutes, $seconds); }); } /** * Word count of the stored transcript (0 when empty). */ protected function wordCount(): Attribute { return Attribute::get(function (): int { if (! filled($this->transcript)) { return 0; } return count(preg_split('/\s+/u', trim($this->transcript), -1, PREG_SPLIT_NO_EMPTY) ?: []); }); } /** * Friendly label for the selected transcription engine. */ protected function transcriptionDriverLabel(): Attribute { return Attribute::get(function (): ?string { return match ($this->transcription_driver) { 'local' => 'Local (faster-whisper)', default => $this->transcription_driver ?: 'Local (faster-whisper)', }; }); } /** * Whether transcription is actively running or queued. */ public function isTranscribing(): bool { return in_array($this->transcription_status, ['pending', 'processing'], true); } /** * Queue a new local faster-whisper transcription run. */ public function queueLocalTranscription(): void { // Only stop a real in-flight/queued run — bare "pending" uploads have no job yet. if ($this->transcription_status === 'processing' || $this->hasActiveTranscriptionJob()) { $this->cancelTranscription(silent: true); $this->refresh(); } $this->update([ 'transcription_driver' => 'local', 'ollama_url' => null, 'transcription_status' => 'pending', 'transcription_progress' => 'Queued — waiting to start…', 'transcription_percent' => null, 'transcription_started_at' => now(), 'transcription_error' => null, // Keep the previous transcript until a new run succeeds. 'transcribed_at' => $this->transcribed_at, ]); $recording = $this->fresh(); RecordingTranscriptionUpdated::dispatch($recording); TranscribeRecording::dispatch($recording); } /** * Human-readable transcription status for badges. */ public function transcriptionStatusLabel(): string { return match ($this->transcription_status) { 'pending' => 'Queued', 'processing' => 'Transcribing', 'done' => 'Done', 'failed' => 'Failed', 'cancelled' => 'Cancelled', default => (string) $this->transcription_status, }; } /** * Search title, metadata, and stored transcript text. */ #[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); }); } /** * First line of the transcript, truncated for compact list rows. */ public function transcriptFirstLine(int $limit = 120): ?string { if (! filled($this->transcript)) { return null; } $firstLine = Str::of($this->transcript) ->before("\n") ->replaceMatches('/\s+/', ' ') ->trim() ->toString(); if ($firstLine === '') { return null; } return Str::limit($firstLine, $limit); } /** * 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 $this->payloadMentionsRecording($payload); } return $job instanceof TranscribeRecording && (int) $job->recording->getKey() === (int) $this->id; }); } /** * Fallback payload match when unserialize is unavailable. */ private function payloadMentionsRecording(string $payload): bool { return (bool) preg_match('/id";i:'.$this->id.';/', $payload) || str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$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; if ($reference === null) { return true; } // Only treat as orphaned after the job could not possibly still be running. // (A short grace caused false failures while Whisper was still working.) $orphanAfterSeconds = max(120, (int) config('ai.transcription_timeout', 600) + 60); return $reference->lte(now()->subSeconds($orphanAfterSeconds)); } /** * Whether a payload/job belongs to this recording's transcription run start time. */ public function matchesTranscriptionRun(?string $runStartedAt): bool { if ($runStartedAt === null || $this->transcription_started_at === null) { return false; } return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp(); } /** * Whether this recording still expects results for the given run. */ public function ownsTranscriptionRun(?string $runStartedAt): bool { $this->refresh(); if (! $this->isTranscribing()) { return false; } return $this->matchesTranscriptionRun($runStartedAt); } /** * 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(); RecordingTranscriptionUpdated::dispatch($this->fresh()); } /** * 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(); RecordingTranscriptionUpdated::dispatch($this->fresh()); } /** * 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. */ public function reportProgress(string $message, int $percent, string $status = 'processing'): void { $this->forceFill([ 'transcription_status' => $status, 'transcription_progress' => $message, 'transcription_percent' => max(0, min(100, $percent)), 'transcription_error' => null, ])->save(); RecordingTranscriptionUpdated::dispatch($this->fresh()); } /** * Broadcast the current transcription status to connected browsers. */ public function broadcastTranscriptionUpdated(): void { RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this); } /** * Absolute filesystem path for the stored audio file. */ public function absolutePath(): string { 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. */ public function deleteFile(): void { if ($this->file_path && Storage::disk('local')->exists($this->file_path)) { Storage::disk('local')->delete($this->file_path); } } /** * Payload for the live status endpoint / Alpine poller. * * @return array */ public function transcriptionStatusPayload(): array { $startedAt = $this->transcription_started_at; $elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null; return [ 'id' => $this->id, 'status' => $this->transcription_status, 'status_label' => $this->transcriptionStatusLabel(), 'progress' => $this->transcription_progress, 'percent' => $this->transcription_percent, 'driver' => $this->transcription_driver, 'driver_label' => $this->transcription_driver_label, 'error' => $this->transcription_error, 'started_at' => $startedAt?->toIso8601String(), 'elapsed_seconds' => $elapsed, 'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed), 'duration_seconds' => $this->duration_seconds, 'is_active' => $this->isTranscribing(), 'has_transcript' => filled($this->transcript), 'transcript' => $this->transcript, 'transcribed_at' => $this->transcribed_at?->toIso8601String(), ]; } private function formatElapsed(int $seconds): string { $minutes = intdiv($seconds, 60); $remain = $seconds % 60; if ($minutes === 0) { return sprintf('%ds', $remain); } return sprintf('%dm %02ds', $minutes, $remain); } }