feat(media): wire Recorder upload to /api/media/audio; fix multer TS types; add S3 download helper

This commit is contained in:
Ender 2025-10-23 21:24:52 +02:00
parent c94461b460
commit 4ad9c311a2
3 changed files with 52 additions and 4 deletions

View File

@ -6,6 +6,8 @@ export default function Recorder() {
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [uploadKey, setUploadKey] = useState<string | null>(null);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const requestStream = async (): Promise<MediaStream | null> => { const requestStream = async (): Promise<MediaStream | null> => {
@ -39,6 +41,7 @@ export default function Recorder() {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return url; return url;
}); });
setAudioBlob(blob);
// stop all tracks to release mic // stop all tracks to release mic
stream.getTracks().forEach(t => t.stop()); stream.getTracks().forEach(t => t.stop());
}; };
@ -52,6 +55,31 @@ export default function Recorder() {
setRecording(false); setRecording(false);
}; };
const uploadAudio = async () => {
try {
setError('');
setUploadKey(null);
if (!audioBlob) {
setError('No audio to upload');
return;
}
const form = new FormData();
form.append('audio', audioBlob, 'recording.webm');
const res = await fetch('/api/media/audio', {
method: 'POST',
body: form,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Upload failed: ${res.status} ${txt}`);
}
const data = await res.json();
setUploadKey(data.key || 'uploaded');
} catch (e: any) {
setError(e?.message || 'Upload failed');
}
};
useEffect(() => { useEffect(() => {
return () => { return () => {
if (audioUrl) URL.revokeObjectURL(audioUrl); if (audioUrl) URL.revokeObjectURL(audioUrl);
@ -64,6 +92,7 @@ export default function Recorder() {
<Stack direction="row" spacing={2} sx={{ mb: 2 }}> <Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" disabled={recording} onClick={startRecording}>Start</Button> <Button variant="contained" disabled={recording} onClick={startRecording}>Start</Button>
<Button variant="outlined" disabled={!recording} onClick={stopRecording}>Stop</Button> <Button variant="outlined" disabled={!recording} onClick={stopRecording}>Stop</Button>
<Button variant="text" disabled={!audioBlob} onClick={uploadAudio}>Upload</Button>
</Stack> </Stack>
{error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>} {error && <Typography color="error" sx={{ mb: 2 }}>{error}</Typography>}
{audioUrl && ( {audioUrl && (
@ -71,6 +100,11 @@ export default function Recorder() {
<audio controls src={audioUrl} /> <audio controls src={audioUrl} />
</Box> </Box>
)} )}
{uploadKey && (
<Typography variant="body2" sx={{ mt: 1 }}>
Uploaded as key: {uploadKey}
</Typography>
)}
</Box> </Box>
); );
} }

View File

@ -1,6 +1,5 @@
import express from 'express'; import express from 'express';
import multer from 'multer'; import multer from 'multer';
import type { File as MulterFile } from 'multer';
import crypto from 'crypto'; import crypto from 'crypto';
import { uploadBuffer } from './storage/s3'; import { uploadBuffer } from './storage/s3';
@ -8,7 +7,7 @@ const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
router.post('/audio', upload.single('audio'), async ( router.post('/audio', upload.single('audio'), async (
req: express.Request & { file?: MulterFile }, req: express.Request,
res: express.Response res: express.Response
) => { ) => {
try { try {

View File

@ -1,6 +1,6 @@
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
function getS3Client() { export function getS3Client() {
const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000 const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000
const region = process.env.S3_REGION || 'us-east-1'; const region = process.env.S3_REGION || 'us-east-1';
const accessKeyId = process.env.S3_ACCESS_KEY || ''; const accessKeyId = process.env.S3_ACCESS_KEY || '';
@ -34,3 +34,18 @@ export async function uploadBuffer(params: {
await s3.send(cmd); await s3.send(cmd);
return { bucket: params.bucket, key: params.key }; return { bucket: params.bucket, key: params.key };
} }
export async function downloadObject(params: { bucket: string; key: string }): Promise<{ buffer: Buffer; contentType: string }> {
const s3 = getS3Client();
const cmd = new GetObjectCommand({ Bucket: params.bucket, Key: params.key });
const res = await s3.send(cmd);
const contentType = res.ContentType || 'application/octet-stream';
const body = res.Body as unknown as NodeJS.ReadableStream;
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
body.on('data', (c: Buffer) => chunks.push(c));
body.on('end', resolve);
body.on('error', reject);
});
return { buffer: Buffer.concat(chunks), contentType };
}