Rayhan Memon
Rayhan Memon

Reputation: 186

Trying to retrieve an mp3 file stored in AWS S3 and load it into my React client as a Blob...it's not working

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:

enter image description here

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: enter image description here

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. enter image description here

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:

enter image description here

Upvotes: 4

Views: 8730

Answers (4)

Daniel Santana
Daniel Santana

Reputation: 1847

This was tough. Here is the way I managed to solve this challenge.

2024 - AWS SDK v3, React & TypeScript

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:

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

React Code:

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 - How to Find the File Key

A bit of documentation:

  1. If you need help installing the latest S3Client, start from here
  2. More about the GetObjectCommand
  3. By doing this (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.
  4. I used 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

msbit
msbit

Reputation: 4320

Based on the output of res.data on the client, there are a couple of things that you'd need to do:

  • Replace uses of res.data.Body with res.data.Body.data (as the actual data array is in the data attribute of res.data.Body)
  • Pass a Uint8Array to the Blob constructor, as the existing array is of a larger type, which will create an invalid blob

Putting 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

Ravi L
Ravi L

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:

  1. Return the data as a 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

  1. Instead of returning the entire MP3 file, have your 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

smac2020
smac2020

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.

enter image description here

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

Related Questions