From a779d4651e3cb962529e9d64aaecd8fd49fec9ce Mon Sep 17 00:00:00 2001 From: carlosjepard Date: Tue, 3 Dec 2024 11:11:20 +0000 Subject: [PATCH] Video streaming working --- .../common/ConsumesSkipableOutputStream.java | 23 ++++++ .../common/api/utils/ApiUtils.java | 42 +++++++++- .../common/api/utils/DownloadUtils.java | 3 +- .../common/api/v1/CollectionResource.java | 38 +++++---- .../storage/BinaryConsumesOutputStream.java | 78 +++++++++++++++++++ 5 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/databasepreservation/common/api/common/ConsumesSkipableOutputStream.java create mode 100644 src/main/java/com/databasepreservation/common/server/storage/BinaryConsumesOutputStream.java diff --git a/src/main/java/com/databasepreservation/common/api/common/ConsumesSkipableOutputStream.java b/src/main/java/com/databasepreservation/common/api/common/ConsumesSkipableOutputStream.java new file mode 100644 index 000000000..0f9acb542 --- /dev/null +++ b/src/main/java/com/databasepreservation/common/api/common/ConsumesSkipableOutputStream.java @@ -0,0 +1,23 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package com.databasepreservation.common.api.common; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Path; + +import org.roda.core.data.exceptions.AuthorizationDeniedException; +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.exceptions.NotFoundException; +import org.roda.core.data.exceptions.RequestNotValidException; + +public interface ConsumesSkipableOutputStream extends ConsumesOutputStream { + + void consumeOutputStream(OutputStream out, long from, long end); + +} diff --git a/src/main/java/com/databasepreservation/common/api/utils/ApiUtils.java b/src/main/java/com/databasepreservation/common/api/utils/ApiUtils.java index 7d73ebe01..09dc3985e 100644 --- a/src/main/java/com/databasepreservation/common/api/utils/ApiUtils.java +++ b/src/main/java/com/databasepreservation/common/api/utils/ApiUtils.java @@ -9,6 +9,7 @@ import java.io.IOException; import java.io.OutputStream; +import java.time.Duration; import java.util.Date; import java.util.concurrent.TimeUnit; @@ -16,11 +17,15 @@ import org.roda.core.data.common.RodaConstants; import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRange; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import com.databasepreservation.common.server.storage.BinaryConsumesOutputStream; + import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.core.StreamingOutput; @@ -85,6 +90,38 @@ public static String getMediaType(final String acceptFormat, final String accept return mediaType; } + public static ResponseEntity rangeResponse(HttpHeaders headers, BinaryConsumesOutputStream streamResponse) { + + // BinaryConsumesOutputStream responseStream = outputStream -> + // streamResponse.getStream().consumeOutputStream(outputStream); + final HttpHeaders responseHeaders = new HttpHeaders(); + + HttpRange range = headers.getRange().get(0); + long start = range.getRangeStart(streamResponse.getSize()); + long end = range.getRangeEnd(streamResponse.getSize()); + + String contentLength = String.valueOf((end - start) + 1); + responseHeaders.add(HttpHeaders.CONTENT_TYPE, streamResponse.getMediaType()); + responseHeaders.add(HttpHeaders.CONTENT_LENGTH, contentLength); + responseHeaders.add(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=\"" + streamResponse.getFileName() + "\""); + responseHeaders.add(HttpHeaders.ACCEPT_RANGES, "bytes"); + responseHeaders.add(HttpHeaders.CONTENT_RANGE, + "bytes" + " " + start + "-" + end + "/" + streamResponse.getSize()); + + StreamingResponseBody responseStream = os -> streamResponse.consumeOutputStream(os, start, end); + + Date lastModifiedDate = streamResponse.getLastModified(); + if (lastModifiedDate != null) { + CacheControl cacheControl = CacheControl.empty().cachePrivate().sMaxAge(Duration.ofSeconds(60)); + responseHeaders.add(HttpHeaders.CACHE_CONTROL, cacheControl.getHeaderValue()); + responseHeaders.setETag(Long.toString(lastModifiedDate.getTime())); + responseHeaders.add(HttpHeaders.LAST_MODIFIED, streamResponse.getLastModified().toString()); + } + + return new ResponseEntity<>(responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT); + } + public static ResponseEntity okResponse(StreamResponse streamResponse) { return okResponse(streamResponse, false); } @@ -131,7 +168,8 @@ public static ResponseEntity okResponse(StreamResponse st responseHeaders.add("Content-Type", streamResponse.getStream().getMediaType()); responseHeaders.add(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + streamResponse.getStream().getFileName() + "\""); - responseHeaders.add("Content-Length", String.valueOf(streamResponse.getStream().getSize())); + // responseHeaders.add("Content-Length", + // String.valueOf(streamResponse.getStream().getSize())); Date lastModifiedDate = streamResponse.getStream().getLastModified(); @@ -144,4 +182,4 @@ public static ResponseEntity okResponse(StreamResponse st return ResponseEntity.ok().headers(responseHeaders).body(responseStream); } -} +} \ No newline at end of file diff --git a/src/main/java/com/databasepreservation/common/api/utils/DownloadUtils.java b/src/main/java/com/databasepreservation/common/api/utils/DownloadUtils.java index 8449cf7fd..0f1cfc1c6 100644 --- a/src/main/java/com/databasepreservation/common/api/utils/DownloadUtils.java +++ b/src/main/java/com/databasepreservation/common/api/utils/DownloadUtils.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Date; @@ -99,7 +100,7 @@ public Date getLastModified() { } @Override - public long getSize() { + public long getSize() { return -1; } }; diff --git a/src/main/java/com/databasepreservation/common/api/v1/CollectionResource.java b/src/main/java/com/databasepreservation/common/api/v1/CollectionResource.java index baae77bab..eb92e5abe 100644 --- a/src/main/java/com/databasepreservation/common/api/v1/CollectionResource.java +++ b/src/main/java/com/databasepreservation/common/api/v1/CollectionResource.java @@ -27,13 +27,13 @@ import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; -import com.databasepreservation.common.api.utils.ExtraMediaType; -import com.databasepreservation.model.exception.ModuleException; -import com.databasepreservation.common.api.exceptions.RESTException; -import com.databasepreservation.common.exceptions.AuthorizationException; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang.StringUtils; -import org.roda.core.data.exceptions.*; +import org.roda.core.data.exceptions.AlreadyExistsException; +import org.roda.core.data.exceptions.AuthorizationDeniedException; +import org.roda.core.data.exceptions.GenericException; +import org.roda.core.data.exceptions.NotFoundException; +import org.roda.core.data.exceptions.RequestNotValidException; import org.roda.core.data.utils.JsonUtils; import org.roda.core.data.v2.index.sublist.Sublist; import org.springframework.batch.core.BatchStatus; @@ -42,7 +42,6 @@ import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.JobParametersInvalidException; -import org.springframework.batch.core.configuration.JobRegistry; import org.springframework.batch.core.explore.JobExplorer; import org.springframework.batch.core.launch.JobLauncher; import org.springframework.batch.core.launch.JobOperator; @@ -53,17 +52,21 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import com.databasepreservation.common.api.exceptions.RESTException; import com.databasepreservation.common.api.utils.ApiUtils; import com.databasepreservation.common.api.utils.DownloadUtils; +import com.databasepreservation.common.api.utils.ExtraMediaType; import com.databasepreservation.common.api.utils.HandlebarsUtils; import com.databasepreservation.common.api.utils.StreamResponse; import com.databasepreservation.common.api.utils.ViewerStreamingOutput; @@ -75,7 +78,6 @@ import com.databasepreservation.common.client.ViewerConstants; import com.databasepreservation.common.client.common.search.SavedSearch; import com.databasepreservation.common.client.common.search.SearchInfo; -import com.databasepreservation.common.exceptions.SavedSearchException; import com.databasepreservation.common.client.index.FindRequest; import com.databasepreservation.common.client.index.IndexResult; import com.databasepreservation.common.client.index.filter.Filter; @@ -95,6 +97,8 @@ import com.databasepreservation.common.client.models.user.User; import com.databasepreservation.common.client.services.CollectionService; import com.databasepreservation.common.client.tools.ViewerStringUtils; +import com.databasepreservation.common.exceptions.AuthorizationException; +import com.databasepreservation.common.exceptions.SavedSearchException; import com.databasepreservation.common.exceptions.ViewerException; import com.databasepreservation.common.server.ViewerConfiguration; import com.databasepreservation.common.server.ViewerFactory; @@ -107,6 +111,7 @@ import com.databasepreservation.common.server.index.utils.IterableIndexResult; import com.databasepreservation.common.server.index.utils.JsonTransformer; import com.databasepreservation.common.server.index.utils.SolrUtils; +import com.databasepreservation.common.server.storage.BinaryConsumesOutputStream; import com.databasepreservation.common.utils.ControllerAssistant; import com.databasepreservation.common.utils.LobManagerUtils; import com.databasepreservation.common.utils.UserUtility; @@ -164,8 +169,7 @@ public ResponseEntity getReport(@PathVariable(name = "databaseUUID") S InputStreamResource resource = new InputStreamResource(new FileInputStream(reportPath.toFile())); return ResponseEntity.ok() .header("Content-Disposition", "attachment; filename=\"" + reportPath.toFile().getName() + "\"") - .contentLength(reportPath.toFile().length()) - .contentType(MediaType.APPLICATION_OCTET_STREAM).body(resource); + .contentLength(reportPath.toFile().length()).contentType(MediaType.APPLICATION_OCTET_STREAM).body(resource); } catch (NotFoundException | IOException | AuthorizationException e) { state = LogEntryState.FAILURE; throw new RESTException(e); @@ -503,7 +507,8 @@ public ResponseEntity exportLOB( @PathVariable(name = ViewerConstants.API_PATH_PARAM_DATABASE_UUID) String databaseUUID, @PathVariable(name = ViewerConstants.API_PATH_PARAM_COLLECTION_UUID) String collectionUUID, @PathVariable(name = "schema") String schema, @PathVariable(name = "table") String table, - @PathVariable(name = "rowIndex") String rowIndex, @PathVariable(name = "columnIndex") Integer columnIndex) { + @PathVariable(name = "rowIndex") String rowIndex, @PathVariable(name = "columnIndex") Integer columnIndex, + @RequestHeader HttpHeaders headers) { ControllerAssistant controllerAssistant = new ControllerAssistant() {}; @@ -532,7 +537,7 @@ public ResponseEntity exportLOB( return handleExternalLobDownload(configTable, row, columnIndex); } else { String version = ViewerFactory.getSolrManager().retrieve(ViewerDatabase.class, databaseUUID).getVersion(); - return handleInternalLobDownload(database.getPath(), configTable, row, columnIndex, version); + return handleInternalLobDownload(database.getPath(), configTable, row, columnIndex, version, headers); } } } catch (NotFoundException | GenericException | IOException | AuthorizationException e) { @@ -601,7 +606,7 @@ private ResponseEntity handleExternalLobDownload(TableSta } private ResponseEntity handleInternalLobDownload(String databasePath, - TableStatus tableConfiguration, ViewerRow row, int columnIndex, String version) + TableStatus tableConfiguration, ViewerRow row, int columnIndex, String version, HttpHeaders headers) throws IOException, GenericException { String handlebarsFilename = HandlebarsUtils.applyExportTemplate(row, tableConfiguration, columnIndex); @@ -636,6 +641,13 @@ private ResponseEntity handleInternalLobDownload(String d return ApiUtils.okResponse(new StreamResponse(handlebarsFilename, handlebarsMimeType, DownloadUtils.stream(new BufferedInputStream(new FileInputStream(tempZipFile.toFile()))))); } else { + + if (!headers.getRange().isEmpty()) { + return ApiUtils.rangeResponse(headers, + new BinaryConsumesOutputStream(Path.of(filePath), Path.of(filePath).toFile().length(), handlebarsFilename, + handlebarsMimeType)); + } + return ApiUtils.okResponse(new StreamResponse(handlebarsFilename, handlebarsMimeType, DownloadUtils.stream(new BufferedInputStream(new FileInputStream(filePath))))); } @@ -963,4 +975,4 @@ public void deleteSavedSearch(String databaseUUID, String collectionUUID, String databaseUUID, ViewerConstants.CONTROLLER_SAVED_SEARCH_UUID_PARAM, savedSearchUUID); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/databasepreservation/common/server/storage/BinaryConsumesOutputStream.java b/src/main/java/com/databasepreservation/common/server/storage/BinaryConsumesOutputStream.java new file mode 100644 index 000000000..b3a8475a4 --- /dev/null +++ b/src/main/java/com/databasepreservation/common/server/storage/BinaryConsumesOutputStream.java @@ -0,0 +1,78 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE file at the root of the source + * tree and available online at + * + * https://github.com/keeps/roda + */ +package com.databasepreservation.common.server.storage; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.nio.file.Path; +import java.util.Date; + +import com.databasepreservation.common.api.common.ConsumesSkipableOutputStream; + +public class BinaryConsumesOutputStream implements ConsumesSkipableOutputStream { + private final Path path; + private final long size; + private final String filename; + private final String mediaType; + + public BinaryConsumesOutputStream(Path path, long size, String filename, String mediaType) { + this.path = path; + this.size = size; + this.filename = filename; + this.mediaType = mediaType; + } + + @Override + public void consumeOutputStream(OutputStream output) throws IOException { + // TODO document why this method is empty + } + + @Override + public Date getLastModified() { + return null; + } + + + @Override + public long getSize() { + return this.size; + } + + @Override + public void consumeOutputStream(OutputStream out, long from, long end) { + try { + File file = path.toFile(); + byte[] buffer = new byte[1024]; + try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) { + long pos = from; + randomAccessFile.seek(pos); + while (pos < end) { + randomAccessFile.read(buffer); + out.write(buffer); + pos += buffer.length; + } + out.flush(); + } + } catch (IOException e) { + // ignore + } + + } + + @Override + public String getFileName() { + return this.filename; + } + + @Override + public String getMediaType() { + return this.mediaType; + } +} \ No newline at end of file