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.
This commit is contained in:
ben
2026-08-12 17:35:48 +02:00
parent 352b564f3a
commit 771ee8db5a
44 changed files with 2581 additions and 424 deletions
+4 -115
View File
@@ -7,6 +7,7 @@
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
Duplicate files (same name and size, or identical content) are skipped.
</p>
</div>
@@ -14,7 +15,7 @@
method="POST"
action="{{ route('recordings.store') }}"
enctype="multipart/form-data"
x-data="uploadDropzone()"
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
@submit="ensureFilesSelected($event)"
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm"
>
@@ -61,7 +62,7 @@
</div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200">
<template x-for="(file, index) in files" :key="fileKey(file, index)">
<template x-for="(file, index) in files" :key="fileListKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0">
<p class="truncate font-medium text-stone-800" x-text="file.name"></p>
@@ -91,6 +92,7 @@
>
</div>
<p x-show="notice" x-cloak class="text-sm text-amber-800" x-text="notice"></p>
<p x-show="error" x-cloak class="text-sm text-red-700" x-text="error"></p>
<div class="flex items-center gap-3">
@@ -104,117 +106,4 @@
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 hover:underline">Cancel</a>
</div>
</form>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak]{display:none!important}</style>
<script>
function uploadDropzone() {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
return {
files: [],
dragging: false,
uploading: false,
error: null,
get uploadLabel() {
if (this.uploading) return 'Uploading…';
if (this.files.length <= 1) return 'Upload';
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
const accepted = [];
for (const file of incoming) {
if (! this.isAccepted(file)) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
continue;
}
if (file.size > 100 * 1024 * 1024) {
this.error = 'Skipped a file larger than 100 MB.';
continue;
}
accepted.push(file);
}
const merged = [...this.files, ...accepted];
const unique = [];
const seen = new Set();
for (const file of merged) {
const key = this.fileKey(file);
if (seen.has(key)) continue;
seen.add(key);
unique.push(file);
}
if (unique.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = unique.slice(0, 50);
} else {
this.files = unique;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) return true;
return (file.type || '').startsWith('audio/');
},
fileKey(file, index = 0) {
return [file.name, file.size, file.lastModified, index].join(':');
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) return;
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
},
};
}
</script>
@endsection
+134 -84
View File
@@ -3,94 +3,144 @@
@section('title', 'Recordings')
@section('content')
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
Search
</button>
</form>
@if (($pendingCount ?? 0) > 0)
<form method="POST" action="{{ route('recordings.transcribe-pending') }}">
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline">
Queue {{ $pendingCount }} pending {{ \Illuminate\Support\Str::plural('transcription', $pendingCount) }}
@php
$indexRows = $recordings->map(function ($recording) {
return [
'id' => $recording->id,
'status' => $recording->transcription_status,
'status_label' => $recording->transcriptionStatusLabel(),
'progress' => $recording->transcription_progress,
'percent' => $recording->transcription_percent,
'is_active' => $recording->isTranscribing(),
'word_count' => $recording->word_count,
'word_count_display' => $recording->word_count > 0
? number_format($recording->word_count)
: '—',
'badge_class' => match ($recording->transcription_status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20',
},
];
})->values();
@endphp
<div
x-data="recordingsIndex(@js([
'recordings' => $indexRows,
'pendingCount' => $pendingCount ?? 0,
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50">
Search
</button>
</form>
@endif
<form
method="POST"
action="{{ route('recordings.transcribe-pending') }}"
x-show="pendingCount > 0"
x-cloak
>
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline">
Queue <span x-text="pendingCount"></span> pending
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
</button>
</form>
</div>
</div>
</div>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
<p class="text-stone-600">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-stone-200 text-sm">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-100">
@foreach ($recordings as $recording)
<tr class="hover:bg-stone-50">
<td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline">
{{ $recording->title }}
</a>
@if ($recording->artist)
<div class="text-xs text-stone-500">{{ $recording->artist }}</div>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500">
{{ $snippet }}
</p>
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
<td class="px-4 py-3 tabular-nums text-stone-600">
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
@include('recordings.partials.status-badge', [
'status' => $recording->transcription_status,
'label' => $recording->transcriptionStatusLabel(),
])
@if ($recording->isTranscribing() && $recording->transcription_progress)
<div class="mt-1 max-w-[14rem] truncate text-xs text-amber-700" title="{{ $recording->transcription_progress }}">
{{ $recording->transcription_percent ? $recording->transcription_percent.'% · ' : '' }}{{ $recording->transcription_progress }}
</div>
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center">
<p class="text-stone-600">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-stone-200 text-sm">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
@endforeach
</tbody>
</table>
</div>
</thead>
<tbody class="divide-y divide-stone-100">
@foreach ($recordings as $recording)
<tr class="hover:bg-stone-50">
<td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline">
{{ $recording->title }}
</a>
@if ($recording->artist)
<div class="text-xs text-stone-500">{{ $recording->artist }}</div>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500">
{{ $snippet }}
</p>
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
<td
class="px-4 py-3 tabular-nums text-stone-600"
x-text="row({{ $recording->id }}).word_count_display"
>
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="row({{ $recording->id }}).badge_class"
x-text="row({{ $recording->id }}).status_label"
>
{{ $recording->transcriptionStatusLabel() }}
</span>
<div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700"
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
x-cloak
:title="row({{ $recording->id }}).progress"
>
<span x-text="row({{ $recording->id }}).percent ? (row({{ $recording->id }}).percent + '% · ') : ''"></span>
<span x-text="row({{ $recording->id }}).progress"></span>
</div>
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
</div>
@endsection
+4 -117
View File
@@ -8,7 +8,10 @@
'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(),
]))"
x-init="start()"
x-init="
start();
return () => destroy();
"
>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
@@ -173,120 +176,4 @@
</p>
</section>
</div>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak]{display:none!important}</style>
<script>
function transcriptionMonitor({ statusUrl, initial }) {
return {
statusUrl,
status: initial,
pollError: null,
timer: null,
tickTimer: null,
get badgeClass() {
const map = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20',
failed: 'bg-red-50 text-red-800 ring-red-600/20',
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20',
};
return map[this.status.status] || 'bg-stone-100 text-stone-700 ring-stone-500/20';
},
get startButtonLabel() {
if (this.status.is_active) {
return 'Restart transcription';
}
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
},
start() {
if (this.status.is_active) {
this.beginPolling();
}
},
beginPolling() {
this.stopPolling();
this.poll();
this.timer = setInterval(() => this.poll(), 1500);
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
},
stopPolling() {
if (this.timer) clearInterval(this.timer);
if (this.tickTimer) clearInterval(this.tickTimer);
this.timer = null;
this.tickTimer = null;
},
tickElapsed() {
if (!this.status.is_active || this.status.elapsed_seconds == null) return;
this.status.elapsed_seconds += 1;
this.status.elapsed_human = this.formatElapsed(this.status.elapsed_seconds);
},
async poll() {
try {
const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Status request failed (' + response.status + ')');
}
const wasActive = this.status.is_active;
this.status = await response.json();
this.pollError = null;
if (this.status.is_active && !this.timer) {
this.beginPolling();
}
if (wasActive && !this.status.is_active) {
this.stopPolling();
if (this.status.has_transcript || this.status.status === 'failed' || this.status.status === 'cancelled') {
return;
}
window.location.reload();
}
} catch (error) {
this.pollError = error.message || 'Could not refresh progress.';
}
},
formatElapsed(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
if (minutes === 0) return remain + 's';
return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
},
formatDuration(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
return minutes + ':' + String(remain).padStart(2, '0');
},
formatTimestamp(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const pad = (n) => String(n).padStart(2, '0');
return date.getFullYear()
+ '-' + pad(date.getMonth() + 1)
+ '-' + pad(date.getDate())
+ ' ' + pad(date.getHours())
+ ':' + pad(date.getMinutes());
},
};
}
</script>
@endsection