0

I try to implement a springboot app that allow me to :

  • upload mp3 on S3
  • extract meta data and store them on mongo DB.

If i want to extract meta data from a mp3, I have to provide MultipartFile. Unfortunatelly, we can't send Flux.

In my controller, I do pass 3 parameters:

@PostMapping
public Mono<ResponseEntity<AudioDto>> saveAudioTrack(@RequestHeader HttpHeaders headers,
                                                     @RequestHeader String fileName,
                                                     @RequestBody Flux<ByteBuffer> body) {
    return this.audioService.saveAudioTrack(headers, fileName, body);
}

Now i'm a little bit stuck in my service layer. I want to build my response, store data and post file onto S3. For storing the metadata, mp3agic.

public Mono<ResponseEntity<AudioDto>> saveAudioTrack(HttpHeaders headers, String fileName, Flux<ByteBuffer> body) {
    return Mono.from(body.flatMap(byteBuffer -> {
        Mono<MultipartFile> multipartFileMono = AppUtils.byteBufferToMultipartFile(headers, fileName, body);
        return multipartFileMono;
    }).flatMap(multipartFile -> {
        AudioEntity storedEntity = new AudioEntity();
        try {
            AudioDto audioDto = storeAudioMeta(multipartFile, headers);
            AudioEntity audioEntity = new AudioEntity();
            BeanUtils.copyProperties(audioDto, audioEntity);
            storedEntity = audioRepository.save(audioEntity).block();
        } catch (IOException | InvalidDataException | UnsupportedTagException e) {
            e.printStackTrace();
        }
        AudioDto storedAudio = new AudioDto();
        BeanUtils.copyProperties(storedEntity, storedAudio);
        ResponseEntity<AudioDto> responseEntity = ResponseEntity.status(HttpStatus.OK).body(storedAudio);
        return Mono.just(responseEntity);
    }));
}

I first try to convert byteBufffer to MultipartFile:

public static Mono<MultipartFile> byteBufferToMultipartFile(HttpHeaders headers, String fileName, Flux<ByteBuffer> body) {
    System.out.println(headers);
    return Mono.from(body.flatMap(byteBuffer -> {
        byte[] bytes = byteBuffer.array();
        MultipartFile multipartFile = new MockMultipartFile(fileName, bytes);
        return Mono.just(multipartFile);
    }));
}

Then I call a part of code that should allow me to persist meta data is called

private AudioDto storeAudioMeta(MultipartFile multipartFile, HttpHeaders headers) throws IOException, InvalidDataException, UnsupportedTagException {
    AudioDto audioDto = new AudioDto();
    if (multipartFile.isEmpty()) {
        throw new IllegalStateException("Cannot upload empty file");
    }
    //Check if the file is an image => we'll check if it's a mp3
    if(!multipartFile.getContentType().equals("audio/mpeg")) {
        throw new IllegalStateException("File uploaded is not a mp3");
    }
    InputStream initialStream = multipartFile.getInputStream();
    byte[] buffer = new byte[initialStream.available()];
    initialStream.read(buffer);
    File targetFile = new File("src/main/resources/" + multipartFile.getOriginalFilename());
    try (OutputStream outStream = new FileOutputStream(targetFile)) {
        outStream.write(buffer);
    }

    Mp3File mp3file  = new Mp3File(targetFile.getPath());
    audioDto.setLength((int) mp3file.getLengthInSeconds());
    audioDto.setBitrate(mp3file.getBitrate());
    audioDto.setSampleRate(mp3file.getSampleRate());
    if (mp3file.hasId3v1Tag()) {
        ID3v1 id3v1Tag = mp3file.getId3v1Tag();
        audioDto.setTrack(id3v1Tag.getTrack());
        audioDto.setArtist(id3v1Tag.getArtist());
        audioDto.setTitle(id3v1Tag.getTitle());
        ...
    }
    if (mp3file.hasId3v2Tag()) {
        ID3v2 id3v2Tag = mp3file.getId3v2Tag();
        audioDto.setTrack(id3v2Tag.getTrack());
        ...
        audioDto.setAlbumImage(id3v2Tag.getAlbumImage());
        byte[] albumImageData = id3v2Tag.getAlbumImage();
        if (albumImageData != null) {
            audioDto.setAlbumImageSize(albumImageData.length);
            audioDto.setAlbumImageMimeType(id3v2Tag.getAlbumImageMimeType());
        }
    }

    //get file metadata
    Map<String, String> metadata = new HashMap<>();
    metadata.put("Content-Type", audioDto.getFile().getContentType());
    metadata.put("Content-Length", String.valueOf(audioDto.getFile().getSize()));
    String bucketName = environment.getProperty("amazon.aws.s3.audioBucket");
    String path = String.format("%s/%s", bucketName, UUID.randomUUID());
    String fileName = String.format("%s", audioDto.getFile().getOriginalFilename());
    // try {
    //     fileStore.upload(path, fileName, Optional.of(metadata), 
    audioDto.getFile().getInputStream());
    // } catch (IOException e) {
        throw new IllegalStateException("Failed to upload file", e);
    //}
    audioDto.setFileName(fileName);
    audioDto.setFilePath(path);
    return audioDto;
}

I have error message returned:

java.lang.IllegalStateException: Only one connection receive subscriber allowed.
    at reactor.netty.channel.FluxReceive.startReceiver(FluxReceive.java:182) ~[reactor-netty-core-1.0.12.jar:1.0.12]
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    *__checkpoint ⇢ Handler com.myapp.api.mediastreaming.ui.controller.AudioController#saveAudioTrack(HttpHeaders, String, Flux) [DispatcherHandler]
    *__checkpoint ⇢ HTTP POST "/audio/tracks" [ExceptionHandlingWebHandler]
Stack trace:
        at reactor.netty.channel.FluxReceive.startReceiver(FluxReceive.java:182) ~[reactor-netty-core-1.0.12.jar:1.0.12]
        at reactor.netty.channel.FluxReceive.subscribe(FluxReceive.java:143) ~[reactor-netty-core-1.0.12.jar:1.0.12]
        at reactor.core.publisher.InternalFluxOperator.subscribe(InternalFluxOperator.java:62) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.netty.ByteBufFlux.subscribe(ByteBufFlux.java:339) ~[reactor-netty-core-1.0.12.jar:1.0.12]
        at reactor.core.publisher.InternalFluxOperator.subscribe(InternalFluxOperator.java:62) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.netty.ByteBufFlux.subscribe(ByteBufFlux.java:339) ~[reactor-netty-core-1.0.12.jar:1.0.12]
        at reactor.core.publisher.Mono.subscribe(Mono.java:4399) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.core.publisher.FluxFlatMap$FlatMapMain.onNext(FluxFlatMap.java:426) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.core.publisher.FluxSwitchIfEmpty$SwitchIfEmptySubscriber.onNext(FluxSwitchIfEmpty.java:74) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onNext(FluxOnErrorResume.java:79) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:120) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:120) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.core.publisher.FluxPeek$PeekSubscriber.onNext(FluxPeek.java:200) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:120) ~[reactor-core-3.4.11.jar:3.4.11]
        at reactor.netty.channel.FluxReceive.drainReceiver(FluxReceive.java:279) ~[reactor-netty-core-1.0.12.jar:1.0.12]
        at reactor.netty.channel.FluxReceive.onInboundNext(FluxReceive.java:388) ~[reactor-netty-core-1.0.12.jar:1.0.12]
        at reactor.netty.channel.ChannelOperations.onInboundNext(ChannelOperations.java:404) ~[reactor-netty-core-1.0.12.jar:1.0.12]
        at reactor.netty.http.server.HttpServerOperations.onInboundNext(HttpServerOperations.java:584) ~[reactor-netty-http-1.0.12.jar:1.0.12]
        at reactor.netty.channel.ChannelOperationsHandler.channelRead(ChannelOperationsHandler.java:93) ~[reactor-netty-core-1.0.12.jar:1.0.12]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at reactor.netty.http.server.HttpTrafficHandler.channelRead(HttpTrafficHandler.java:261) ~[reactor-netty-http-1.0.12.jar:1.0.12]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:436) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:324) ~[netty-codec-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:296) ~[netty-codec-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:251) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:719) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:655) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:581) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:493) ~[netty-transport-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:986) ~[netty-common-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) ~[netty-common-4.1.69.Final.jar:4.1.69.Final]
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) ~[netty-common-4.1.69.Final.jar:4.1.69.Final]
        at java.base/java.lang.Thread.run(Thread.java:834) ~[na:na]
Toerktumlare
  • 12,548
  • 3
  • 35
  • 54
davidvera
  • 1,292
  • 2
  • 24
  • 55
  • there are several strange things i can see when i see your code, first why use `Mono.from` and not `flatMap` the `body` directly. Second of all, most IO stuff is blocking, this you can pick up as you have to do a `try/catch` in the middle. Blocking calls should be placed on its own subscriber. You can read about how to handle blocking calls in reactor reference documentation. But first i would remove all unnecassary `Mono.from` calls – Toerktumlare Oct 24 '21 at 16:04
  • well, i did try "all possible combinations" to get the wanted output. It didn't work. my first days in reactive world are rather hard. I followed your advice. And i found out a solution. – davidvera Oct 26 '21 at 17:17
  • your solution is still not proper reactive, all file operations are blocking. And try catch is usually not used in reactive programming. You are mixing imperative programming with reactive programming. If you dont know what i am talking about i suggest you read the `reactor documentation` and especially their getting started section. Stack overflow is not the place to ask for the basics. – Toerktumlare Oct 26 '21 at 17:26
  • 1
    I'm going to return on basics. thanks – davidvera Oct 26 '21 at 17:32

1 Answers1

0

I finally found out a solution, highly inspired by this tutorial: https://www.vinsguru.com/spring-webflux-file-upload/

In my controller :

@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Mono<AudioDto>> saveAudioTrack(@RequestPart("file") Mono<FilePart> filePartMono) {
    return new ResponseEntity<>(this.audioService.saveAudioTrack(filePartMono), HttpStatus.CREATED);
}

In my service layer :

@Override
public Mono<AudioDto> saveAudioTrack(Mono<FilePart> filePartMono) {
    Path basePath = Paths.get("./src/main/resources/upload/");
    return filePartMono
            .doOnNext(fp -> System.out.println("Received File : " + fp.filename()))
            .flatMap(fp -> fp.transferTo(basePath.resolve(fp.filename())))
            .then(
                    filePartMono
                            .flatMap(fp -> Mono.just(new File(String.valueOf(basePath.resolve(fp.filename())))))
                            .map(file -> {
                                try {
                                    return getAudioMetadata(file);
                                } catch (InvalidDataException | UnsupportedTagException | IOException e) {
                                    e.printStackTrace();
                                    throw new RuntimeException(e.getLocalizedMessage());
                                }
                            })
            );

}

I also manage to get meta data and upload file to S3 bucket:

I call my method to create metadata

private AudioDto getAudioMetadata(File file) throws InvalidDataException, UnsupportedTagException, IOException {
    Mp3File mp3file  = new Mp3File(file.getPath());
    AudioDto audioDto = new AudioDto();
   ...
    if (mp3file.hasId3v1Tag()) {
        ID3v1 id3v1Tag = mp3file.getId3v1Tag();
        audioDto.setTrack(id3v1Tag.getTrack());
        ...   
    }
    if (mp3file.hasId3v2Tag()) {
        ID3v2 id3v2Tag = mp3file.getId3v2Tag();
        audioDto.setTrack(id3v2Tag.getTrack());
        ...
        audioDto.setAlbumImage(id3v2Tag.getAlbumImage());
        byte[] albumImageData = id3v2Tag.getAlbumImage();
        if (albumImageData != null) {
            audioDto.setAlbumImageSize(albumImageData.length);
            audioDto.setAlbumImageMimeType(id3v2Tag.getAlbumImageMimeType());
        }
    }

    //get file metadata
    Map<String, String> metadata = new HashMap<>();
    Tika tika = new Tika();

    metadata.put("Content-Type", tika.detect(file));
    metadata.put("Content-Length", String.valueOf(file.length()));
    String path = String.format("%s/%s", environment.getProperty("amazon.aws.s3.audioBucket"), UUID.randomUUID());
    //String fileName = String.format("%s", audioDto.getFile().getOriginalFilename());
    try {
        InputStream inputSteam = new FileInputStream(file);
        fileStore.upload(path, file.getName(), Optional.of(metadata), inputSteam);
    } catch (IOException e) {
        throw new IllegalStateException("Failed to upload file", e);
    }
    audioDto.setFileName(file.getName());
    ...
    ModelMapper modelMapper = new ModelMapper();
    modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
    AudioEntity audioFileEntity = modelMapper.map(audioDto, AudioEntity.class);
    AudioEntity storedEntity = audioRepository.save(audioFileEntity);

    return modelMapper.map(storedEntity, AudioDto.class);
}

here is my upload method:

private final AmazonS3 amazonS3;

public void upload(String path,
                   String fileName,
                   Optional<Map<String, String>> optionalMetaData,
                   InputStream inputStream) {
    ObjectMetadata objectMetadata = new ObjectMetadata();
    optionalMetaData.ifPresent(map -> {
        if (!map.isEmpty()) {
            map.forEach(objectMetadata::addUserMetadata);
        }
    });
    try {
        amazonS3.putObject(path, fileName, inputStream, objectMetadata);
    } catch (AmazonServiceException e) {
        throw new IllegalStateException("Failed to upload the file", e);
    }
}

Not the most elegant way, but functionnal. there 's some workout before telling it's fully operational but nothing tricky. :)

davidvera
  • 1,292
  • 2
  • 24
  • 55