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:
+11
-1
@@ -1 +1,11 @@
|
||||
//
|
||||
import Alpine from 'alpinejs';
|
||||
import './echo';
|
||||
import { recordingsIndex, transcriptionMonitor } from './transcription';
|
||||
import { uploadDropzone } from './upload';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
window.transcriptionMonitor = transcriptionMonitor;
|
||||
window.recordingsIndex = recordingsIndex;
|
||||
window.uploadDropzone = uploadDropzone;
|
||||
|
||||
Alpine.start();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Echo from 'laravel-echo';
|
||||
|
||||
import Pusher from 'pusher-js';
|
||||
window.Pusher = Pusher;
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Shared helpers and Alpine components for live transcription updates via Reverb.
|
||||
*/
|
||||
|
||||
const BADGE_CLASSES = {
|
||||
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',
|
||||
};
|
||||
|
||||
export function badgeClassFor(status) {
|
||||
return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20';
|
||||
}
|
||||
|
||||
export function 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';
|
||||
}
|
||||
|
||||
export function 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');
|
||||
}
|
||||
|
||||
export function 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());
|
||||
}
|
||||
|
||||
function formatWordCount(count) {
|
||||
const n = Number(count) || 0;
|
||||
|
||||
return n > 0 ? n.toLocaleString() : '—';
|
||||
}
|
||||
|
||||
function subscribeToRecordings(handler) {
|
||||
if (!window.Echo) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const channel = window.Echo.channel('recordings');
|
||||
|
||||
channel.listen('.RecordingTranscriptionUpdated', handler);
|
||||
|
||||
return () => {
|
||||
window.Echo.leave('recordings');
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
|
||||
*/
|
||||
export function transcriptionMonitor({ statusUrl, initial }) {
|
||||
return {
|
||||
statusUrl,
|
||||
status: initial,
|
||||
pollError: null,
|
||||
tickTimer: null,
|
||||
leaveChannel: null,
|
||||
|
||||
get badgeClass() {
|
||||
return badgeClassFor(this.status.status);
|
||||
},
|
||||
|
||||
get startButtonLabel() {
|
||||
if (this.status.is_active) {
|
||||
return 'Restart transcription';
|
||||
}
|
||||
|
||||
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
|
||||
},
|
||||
|
||||
start() {
|
||||
this.leaveChannel = subscribeToRecordings((event) => {
|
||||
if (Number(event.id) !== Number(this.status.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyPayload(event);
|
||||
});
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
this.hydrateOnce();
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stopTick();
|
||||
|
||||
if (this.leaveChannel) {
|
||||
this.leaveChannel();
|
||||
this.leaveChannel = null;
|
||||
}
|
||||
},
|
||||
|
||||
applyPayload(payload) {
|
||||
const wasActive = this.status.is_active;
|
||||
this.status = { ...this.status, ...payload };
|
||||
this.pollError = null;
|
||||
|
||||
if (this.status.is_active) {
|
||||
this.beginTick();
|
||||
} else {
|
||||
this.stopTick();
|
||||
}
|
||||
|
||||
if (wasActive && !this.status.is_active
|
||||
&& !this.status.has_transcript
|
||||
&& this.status.status !== 'failed'
|
||||
&& this.status.status !== 'cancelled') {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
|
||||
beginTick() {
|
||||
if (this.tickTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
|
||||
},
|
||||
|
||||
stopTick() {
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
tickElapsed() {
|
||||
if (!this.status.is_active || this.status.elapsed_seconds == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.elapsed_seconds += 1;
|
||||
this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds);
|
||||
},
|
||||
|
||||
async hydrateOnce() {
|
||||
if (!this.statusUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.statusUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Status request failed (' + response.status + ')');
|
||||
}
|
||||
|
||||
this.applyPayload(await response.json());
|
||||
} catch (error) {
|
||||
this.pollError = error.message || 'Could not refresh progress.';
|
||||
}
|
||||
},
|
||||
|
||||
formatElapsed,
|
||||
formatDuration,
|
||||
formatTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Index-page Alpine component: patch row status from Reverb events.
|
||||
*/
|
||||
export function recordingsIndex({ recordings, pendingCount }) {
|
||||
const byId = {};
|
||||
|
||||
for (const row of recordings) {
|
||||
byId[row.id] = row;
|
||||
}
|
||||
|
||||
return {
|
||||
rows: byId,
|
||||
pendingCount: Number(pendingCount) || 0,
|
||||
leaveChannel: null,
|
||||
|
||||
start() {
|
||||
this.leaveChannel = subscribeToRecordings((event) => {
|
||||
this.applyPayload(event);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.leaveChannel) {
|
||||
this.leaveChannel();
|
||||
this.leaveChannel = null;
|
||||
}
|
||||
},
|
||||
|
||||
applyPayload(payload) {
|
||||
const id = payload.id;
|
||||
|
||||
if (!this.rows[id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStatus = this.rows[id].status;
|
||||
const next = {
|
||||
...this.rows[id],
|
||||
status: payload.status,
|
||||
status_label: payload.status_label,
|
||||
progress: payload.progress,
|
||||
percent: payload.percent,
|
||||
is_active: payload.is_active,
|
||||
word_count: payload.word_count ?? this.rows[id].word_count,
|
||||
word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count),
|
||||
badge_class: badgeClassFor(payload.status),
|
||||
};
|
||||
|
||||
this.rows[id] = next;
|
||||
|
||||
const wasQueueable = ['pending', 'failed', 'cancelled'].includes(previousStatus);
|
||||
const isQueueable = ['pending', 'failed', 'cancelled'].includes(payload.status);
|
||||
|
||||
if (wasQueueable && !isQueueable) {
|
||||
this.pendingCount = Math.max(0, this.pendingCount - 1);
|
||||
} else if (!wasQueueable && isQueueable) {
|
||||
this.pendingCount += 1;
|
||||
}
|
||||
},
|
||||
|
||||
row(id) {
|
||||
return this.rows[id] || {};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Upload dropzone: discard duplicate files in the selection (and known server fingerprints)
|
||||
* before submitting the form.
|
||||
*/
|
||||
|
||||
export function uploadDropzone({ existingFingerprints = [] } = {}) {
|
||||
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
|
||||
const known = new Set(existingFingerprints);
|
||||
|
||||
return {
|
||||
files: [],
|
||||
dragging: false,
|
||||
uploading: false,
|
||||
error: null,
|
||||
notice: 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;
|
||||
this.notice = null;
|
||||
|
||||
const accepted = [];
|
||||
let skippedUnsupported = 0;
|
||||
let skippedTooLarge = 0;
|
||||
let skippedDuplicates = 0;
|
||||
|
||||
for (const file of incoming) {
|
||||
if (! this.isAccepted(file)) {
|
||||
skippedUnsupported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
skippedTooLarge++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fingerprint = this.fingerprint(file);
|
||||
|
||||
if (known.has(fingerprint) || this.files.some((existing) => this.fingerprint(existing) === fingerprint)) {
|
||||
skippedDuplicates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (accepted.some((existing) => this.fingerprint(existing) === fingerprint)) {
|
||||
skippedDuplicates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
accepted.push(file);
|
||||
}
|
||||
|
||||
this.files = [...this.files, ...accepted];
|
||||
|
||||
if (this.files.length > 50) {
|
||||
this.error = 'You can upload at most 50 files at once.';
|
||||
this.files = this.files.slice(0, 50);
|
||||
}
|
||||
|
||||
if (skippedUnsupported > 0) {
|
||||
this.error = 'Skipped unsupported file type. Use common audio formats only.';
|
||||
} else if (skippedTooLarge > 0) {
|
||||
this.error = 'Skipped a file larger than 100 MB.';
|
||||
}
|
||||
|
||||
if (skippedDuplicates > 0) {
|
||||
this.notice = skippedDuplicates === 1
|
||||
? 'Skipped 1 duplicate file.'
|
||||
: `Skipped ${skippedDuplicates} duplicate files.`;
|
||||
}
|
||||
|
||||
this.syncInput();
|
||||
},
|
||||
|
||||
isAccepted(file) {
|
||||
const name = (file.name || '').toLowerCase();
|
||||
|
||||
if (acceptExt.some((ext) => name.endsWith(ext))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (file.type || '').startsWith('audio/');
|
||||
},
|
||||
|
||||
fingerprint(file) {
|
||||
return `${String(file.name || '').toLowerCase()}:${Number(file.size) || 0}`;
|
||||
},
|
||||
|
||||
fileListKey(file, index = 0) {
|
||||
return `${this.fingerprint(file)}:${index}`;
|
||||
},
|
||||
|
||||
removeFile(index) {
|
||||
this.files.splice(index, 1);
|
||||
this.syncInput();
|
||||
},
|
||||
|
||||
clearFiles() {
|
||||
this.files = [];
|
||||
this.notice = null;
|
||||
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';
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user