Files
AndyTranscribe/app/Http/Controllers/RecordingController.php
T
ben 771ee8db5a Add Docker stack, Reverb live progress, and duplicate upload detection.
Ship FrankenPHP Compose services with Reverb WebSockets for real-time transcription status, skip re-uploading identical audio via content hash, and format disk usage without requiring intl.
2026-08-12 17:35:48 +02:00

214 lines
6.5 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreRecordingRequest;
use App\Models\Recording;
use App\Services\Mp3MetadataService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class RecordingController extends Controller
{
/**
* Display a listing of recordings.
*/
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->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<UploadedFile> $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;
}
}