Reputation: 186
I have a react web app that allows users to record mp3 files in the browser. These mp3 files are saved in an AWS S3 bucket and can be retrieved and loaded back into the react app during the user's next session.
Saving the file works just fine, but when I try to retrieve the file with getObject() and try to create an mp3 blob on the client-side, I get a small, unusable blob:
Here's the journey the recorded mp3 file goes on:
1) Saving to S3
In my Express/Node server, I receive the uploaded mp3 file and save to the S3 bucket:
//SAVE THE COMPLETED AUDIO TO S3
router.post("/", [auth, upload.array('audio', 12)], async (req, res) => {
try {
//get file
const audioFile = req.files[0];
//create object key
const userId = req.user;
const projectId = req.cookies.currentProject;
const { sectionId } = req.body;
const key = `${userId}/${projectId}/${sectionId}.mp3`;
const fileStream = fs.createReadStream(audioFile.path)
const uploadParams = {
Bucket: bucketName,
Body: fileStream,
Key: key,
ContentType: "audio/mp3"
}
const result = await s3.upload(uploadParams).promise();
res.send(result.key);
} catch (error) {
console.error(error);
res.status(500).send();
}
});
As far as I know, there are no problems at this stage. The file ends up in my S3 bucket with "type: mp3" and "Content-Type: audio/mp3".
2) Loading file from S3 Bucket
When the react app is loaded up, an HTTP GET Request is made in my Express/Node server to retrieve the mp3 file from the S3 Bucket
//LOAD A FILE FROM S3
router.get("/:sectionId", auth, async(req, res) => {
try {
//create key from user/project/section IDs
const sectionId = req.params.sectionId;
const userId = req.user;
const projectId = req.cookies.currentProject;
const key = `${userId}/${projectId}/${sectionId}.mp3`;
const downloadParams = {
Key: key,
Bucket: bucketName
}
s3.getObject(downloadParams, function (error, data) {
if (error) {
console.error(error);
res.status(500).send();
}
res.send(data);
});
} catch (error) {
console.error(error);
res.status(500).send();
}
});
The "data" returned here is as such:
3) Making a Blob URL on the client
Finally, in the React client, I try to create an 'audio/mp3' blob from the returned array buffer
const loadAudio = async () => {
const res = await api.loadAudio(activeSection.sectionId);
const blob = new Blob([res.data.Body], {type: 'audio/mp3' });
const url = URL.createObjectURL(blob);
globalDispatch({ type: "setFullAudioURL", payload: url });
}
The created blob is severely undersized and appears to be completely unusable. Downloading the file results in a 'Failed - No file' error.
I've been stuck on this for a couple of days now with no luck. I would seriously appreciate any advice you can give!
Thanks
EDIT 1
Just some additional info here: in the upload parameters, I set the Content-Type as audio/mp3 explicitly. This is because when not set, the Content-Type defaults to 'application/octet-stream'. Either way, I encounter the same issue with the same result.
EDIT 2 At the request of a commenter, here is the res.data available on the client-side after the call is complete:
Upvotes: 4
Views: 8730
Reputation: 1847
This was tough. Here is the way I managed to solve this challenge.
TL;DR: In your API, the magic comes from using this: (data as any).pipe(response);
, instead of the usual `return response.status(200).send(data). Code:
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3Client = (awsCredentials: AwsCredentials) =>
new S3Client({
region: awsCredentials.region,
credentials: {
accessKeyId: awsCredentials.credentials.access_key_id,
secretAccessKey: awsCredentials.credentials.secret_access_key
}
});
const downloadFromS3 = async (key: string, awsCredentials: AwsCredentials): Promise<any | undefined> => {
const command = new GetObjectCommand({
Bucket: 'your_bucket_name',
Key: key // check the screenshot
});
return (await s3Client(awsCredentials).send(command)).Body;
};
export const register = (app: express.Application): void => {
app.post(
'/api/v2/download-media/:media_id',
async (request: Request, response: Response) => {
const key = 'path/inside_your_bucket/file.extension';
const credentials = {
region: 'your-region',
credentials: {
accessKeyId: 'your_access_key_id',
secretAccessKey: 'your_secret_access_key'
};
const data = await downloadFromS3(key, credentials );
if (!data) {
return response.status(204).send();
}
response.setHeader('Content-Type', 'audio/mp3');
response.setHeader(
'Content-Disposition',
`attachment; filename="${media_id}.${media_extension}"`
);
//This is the magic:
(data as any).pipe(response);
}
);
};
Front End Tip: This will return the data as Blob, and you must let Axios (or fetch) know the expected response. This is the React Code required to get the Mp3 file:
import { useEffect, useState } from 'react';
import axios from 'axios';
export const MyView = (props:IMyViewProps) => {
const [audioUrl, setAudioUrl] = useState<string | undefined>();
const [mediaBlob, setMediaBlob] = useState<Blob | undefined>();
const downloadBlob = async (url: string, data: any, token: string) => {
const axiosInstance = axios.create({
headers: {
Authorization: `Bearer ${token}` // remove if you don't need
},
responseType: 'blob' // this is important
});
return await axiosInstance.post(url, data);
};
const getMessageAudio = async (): Promise<Blob | undefined> => {
try {
const url = `https://your-api-server.domain.com/api/v2/download-media/${props.media_id}`;
const data = { parameter: 'some data you might need to pass to your API' };
const response = await downloadBlob(url, data);
return (await response).data;
} catch {
console.log('Not expected');
}
}
};
useEffect(() => {
if (props.media_id) {
(async () => {
setMediaBlob(await getMessageAudio());
})();
}
}, [props.media_id]);
useEffect(() => {
if (mediaBlob?.size) {
const url = URL.createObjectURL(mediaBlob);
setAudioUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}
}, [mediaBlob?.size]);
(...)
return (
// ...
<audio preload='metadata' controls>
{audioUrl && <source src={audioUrl} type='audio/mpeg' />}
Your browser does not support the audio element.
</audio>
)
};
Tip: the key is the file path inside your S3 Bucket -
(data as any).pipe(response)
, you are returning the Readable Stream itself, something that has been available since earlier versions of NodeJS, so that you won't have any compatibility issues. Also, it tends to perform well and is secure. But if you are dealing with large files, I recommend doing some extra research to see if this is the best strategy in your case. My files are small (50kb, so I don't have performance issues). For more information, check NodeJS Stream documentation.any
as the downloadFromS3 because the '.Body' response type is StreamingBlobPayloadOutputTypes, and following the documentation, I couldn't find a better strategy. This other StackOverflow post suggests the use of @smithy/types
, but this AWS SDK V3 Issue questions the strategy.JIT: Special thanks to @msbit and @Rayhan. Because of this post and MSBit's clear response, I understood what I had to do to make it work. Thanks mate, once again StackOverflow beats chatGPT :))
Upvotes: 0
Reputation: 4320
Based on the output of res.data
on the client, there are a couple of things that you'd need to do:
res.data.Body
with res.data.Body.data
(as the actual data array is in the data
attribute of res.data.Body
)Uint8Array
to the Blob
constructor, as the existing array is of a larger type, which will create an invalid blobPutting that together, you would end up replacing:
const blob = new Blob([res.data.Body], {type: 'audio/mp3' });
with:
const blob = new Blob([new Uint8Array(res.data.Body.data)], {type: 'audio/mp3' });
Having said all that, the underlying issue is that the NodeJS server is sending the content over as a JSON encoded serialisation of the response from S3, which is likely overkill for what you are doing. Instead, you can send the Buffer
across directly, which would involve, on the server side, replacing:
res.send(data);
with:
res.set('Content-Type', 'audio/mp3');
res.send(data.Body);
and on the client side (likely in the loadAudio
method) processing the response as a blob instead of JSON. If using the Fetch API then it could be as simple as:
const blob = await fetch(<URL>).then(x => x.blob());
Upvotes: 4
Reputation: 1230
Your server side code seems alright to me. I'm not super clear about the client-side approach. Do you load this into the blob into the HTML5 Audio player.
I have a few approaches, assuming you're trying to load this into an audio tag in the UI.
<audio controls src="data:audio/mpeg;base64,blahblahblah or html src" />
Assuming that the file you had uploaded to S3 is valid here are two approaches:
base64
string instead of as a buffer directly from S3. You can do this in your server side by returning as const base64MP3 = data.Body.toString('base64');
You can then pass this in to the MP3 player in the src
property and it will play the audio. Prefix it with data:audio/mpeg;base64
sectionID
method return a presigned
S3 URL. Essentially, this is a direct link to the object in S3 that is authorized for say 5 minutes.Then you should be able to use this URL directly to stream the audio and set it as the src. Keep in mind that it will expire.
const url = s3.getSignedUrl('getObject', {
Bucket: myBucket,
Key: myKey,
Expires: signedUrlExpireSeconds
});
Upvotes: 2
Reputation: 10734
You stated: "The created blob is severely undersized and appears to be completely unusable"
This appears to me that you have an encoding issue. Once you read the MP3 from the Amazon S3 bucket, you need to encode it properly so it functions in a web page.
I did a similar multimedia use case that involved MP4 and a Java app. That is, i wanted a MP4 obtained from a bucket to play in the web page - as shown in this example web app.
Once I read the byte stream from the S3 bucket, I had to encode it so it would play in a HTML Video tag. Here is a good reference to properly encode a MP3 file.
Upvotes: 0