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 -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