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->search($search); } $recordings = $query->paginate(20)->withQueryString(); $pendingCount = Recording::query() ->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(): View { $existingFingerprints = Recording::query() ->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, )); $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]) || Recording::query()->where('content_hash', $hash)->exists() || Recording::query() ->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($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 { $recording->recoverOrphanedTranscription(); $recording->refresh(); return view('recordings.show', compact('recording')); } /** * Remove the specified recording. */ public function destroy(Recording $recording): RedirectResponse { $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( 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([ '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; } }