*/ protected $fillable = [ 'title', 'original_filename', 'file_path', 'duration_seconds', 'recorded_at', 'artist', 'album', 'file_size_bytes', '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', ]; } /** * 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); }); } /** * Friendly label for the selected transcription engine. */ protected function transcriptionDriverLabel(): Attribute { return Attribute::get(function (): ?string { return match ($this->transcription_driver) { 'cloud' => 'Cloud (OpenAI Whisper)', 'local' => 'Local (faster-whisper)', 'ollama' => 'Ollama host', default => $this->transcription_driver, }; }); } /** * Whether transcription is actively running or queued. */ public function isTranscribing(): bool { return in_array($this->transcription_status, ['pending', 'processing'], 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(); } /** * Absolute filesystem path for the stored audio file. */ public function absolutePath(): string { return Storage::disk('local')->path($this->file_path); } /** * 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 ? $startedAt->diffInSeconds(now()) : null; return [ 'id' => $this->id, 'status' => $this->transcription_status, '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), '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); } }