diff --git a/WebApp/src/lib/app/controller-media.js b/WebApp/src/lib/app/controller-media.js index af4695f..6532d81 100644 --- a/WebApp/src/lib/app/controller-media.js +++ b/WebApp/src/lib/app/controller-media.js @@ -36,6 +36,8 @@ export const createControllerMediaModule = ({ let cameraVideoElement = null; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const uploadFailureHint = + 'Check MinIO bucket CORS and the public upload host/certificate if browser uploads are failing.'; const updateMotionDetectionRuntime = (updates) => { patchAppState((state) => ({ @@ -148,6 +150,62 @@ export const createControllerMediaModule = ({ return parts.join(' ยท '); }; + const uploadRecordingBlobToStorage = async ({ uploadMeta, blob, streamSessionId = null, eventId = null }) => { + const contentType = blob.type || 'video/webm'; + const recordingId = uploadMeta?.video?.id ?? null; + + try { + const uploadResponse = await fetch(uploadMeta.uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + body: blob + }); + + if (!uploadResponse.ok) { + throw new Error(`Upload failed: ${await summarizeFetchFailure(uploadResponse)}`); + } + + return { + mode: 'direct', + status: uploadResponse.status + }; + } catch (directError) { + const directErrorMessage = + directError instanceof Error ? directError.message : String(directError); + + console.warn('[recording.upload] direct upload failed, retrying via backend proxy', { + recordingId, + streamSessionId, + eventId, + objectKey: uploadMeta?.objectKey, + error: directErrorMessage + }); + addActivity( + 'Recording', + `Direct upload failed for ${uploadMeta?.objectKey ?? 'recording'}, retrying via backend proxy` + ); + + if (!recordingId) { + throw directError; + } + + try { + const proxyUpload = await api.uploads.uploadRecordingBlob(recordingId, blob, contentType); + return { + mode: 'proxy', + status: 201, + proxyUpload + }; + } catch (proxyError) { + const proxyErrorMessage = + proxyError instanceof Error ? proxyError.message : String(proxyError); + throw new Error( + `Direct upload failed (${directErrorMessage}); backend proxy upload failed (${proxyErrorMessage})` + ); + } + } + }; + const refreshCameraInputDevices = async () => { if (!navigator.mediaDevices?.enumerateDevices) { setAppState({ cameraInputDevices: [], selectedCameraInputId: '' }); @@ -566,23 +624,19 @@ export const createControllerMediaModule = ({ 'Recording', `Upload URL ready for ${uploadMeta.objectKey} via ${uploadOrigin}` ); - - const uploadResponse = await fetch(uploadMeta.uploadUrl, { - method: 'PUT', - headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, - body: compressedBlob + const uploadResult = await uploadRecordingBlobToStorage({ + uploadMeta, + blob: compressedBlob, + streamSessionId }); - if (!uploadResponse.ok) { - throw new Error(`Upload failed: ${await summarizeFetchFailure(uploadResponse)}`); - } - console.info('[recording.upload] object uploaded', { recordingId: recording.id, streamSessionId, objectKey: uploadMeta.objectKey, bucket: uploadMeta.bucket, - status: uploadResponse.status, + mode: uploadResult.mode, + status: uploadResult.status, sizeBytes: compressedBlob.size }); addActivity('Recording', `Upload completed for ${uploadMeta.objectKey}`); @@ -604,7 +658,7 @@ export const createControllerMediaModule = ({ addActivity('Recording', 'Recording uploaded and finalized'); return true; } catch (error) { - console.error('[recording.upload] stream upload failed, falling back to simulated key', { + console.error('[recording.upload] stream upload failed before object reached storage', { recordingId: recording.id, streamSessionId, error: error instanceof Error ? error.message : String(error) @@ -613,14 +667,9 @@ export const createControllerMediaModule = ({ 'Recording', `Upload failed for stream ${streamSessionId}: ${error instanceof Error ? error.message : 'unknown error'}` ); - const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`; - await api.events.finalizeRecording(recording.id, { - objectKey: fallbackObjectKey, - durationSeconds: captureResult?.durationSeconds ?? 6, - sizeBytes: captureResult?.blob?.size ?? 5000000 - }); - addActivity('Recording', 'Upload failed; finalized with simulator fallback'); - return true; + addActivity('Recording', uploadFailureHint); + pushToast('Recording upload failed before reaching storage.', 'error'); + return false; } } @@ -672,23 +721,19 @@ export const createControllerMediaModule = ({ blobType: compressedBlob.type || 'video/webm' }); addActivity('Recording', `Standalone upload URL ready via ${uploadOrigin}`); - - const uploadResponse = await fetch(uploadMeta.uploadUrl, { - method: 'PUT', - headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, - body: compressedBlob + const uploadResult = await uploadRecordingBlobToStorage({ + uploadMeta, + blob: compressedBlob, + eventId: lastMotionEventId }); - if (!uploadResponse.ok) { - throw new Error(`Upload failed: ${await summarizeFetchFailure(uploadResponse)}`); - } - console.info('[recording.upload] standalone object uploaded', { eventId: lastMotionEventId, recordingId: uploadMeta.video?.id, objectKey: uploadMeta.objectKey, bucket: uploadMeta.bucket, - status: uploadResponse.status, + mode: uploadResult.mode, + status: uploadResult.status, sizeBytes: compressedBlob.size }); addActivity('Recording', `Standalone upload completed for ${uploadMeta.objectKey}`); @@ -718,6 +763,8 @@ export const createControllerMediaModule = ({ 'Recording', `Standalone motion upload failed: ${error instanceof Error ? error.message : 'unknown error'}` ); + addActivity('Recording', uploadFailureHint); + pushToast('Motion clip upload failed before reaching storage.', 'error'); return false; } };