diff --git a/airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java b/airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java index 3207af01..e5ae6dee 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java +++ b/airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java @@ -128,6 +128,7 @@ public class StreamController { MediaFile file = getSingleFile(request); boolean isSingleFile = file != null; HttpRange range = null; + Long fileLengthExpected = null; if (isSingleFile) { @@ -153,39 +154,36 @@ public class StreamController { TranscodingService.Parameters parameters = transcodingService.getParameters(file, player, maxBitRate, preferredTargetFormat, null); - boolean isConversion = parameters.isDownsample() || parameters.isTranscode(); - boolean estimateContentLength = ServletRequestUtils.getBooleanParameter(request, - "estimateContentLength", false); boolean isHls = ServletRequestUtils.getBooleanParameter(request, "hls", false); + fileLengthExpected = parameters.getExpectedLength(); // Wrangle response length and ranges. // - // Support ranges as long as we're not transcoding; video is always assumed to transcode - if (isConversion || file.isVideo()) { + // Support ranges as long as we're not transcoding blindly; video is always assumed to transcode + if (file.isVideo() || ! parameters.isRangeAllowed()) { // Use chunked transfer; do not accept range requests response.setStatus(HttpServletResponse.SC_OK); response.setHeader("Accept-Ranges", "none"); } else { - // Not transcoding, partial content permitted because we know the final size + // Partial content permitted because either know or expect to be able to predict the final size long contentLength; - // If range was requested, respond in kind - range = getRange(request, file); + range = getRange(request, file.getDurationSeconds(), fileLengthExpected); if (range != null) { response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader("Accept-Ranges", "bytes"); // Both ends are inclusive long startByte = range.getFirstBytePos(); - long endByte = range.isClosed() ? range.getLastBytePos() : file.getFileSize() - 1; + long endByte = range.isClosed() ? range.getLastBytePos() : fileLengthExpected - 1; response.setHeader("Content-Range", - String.format("bytes %d-%d/%d", startByte, endByte, file.getFileSize())); + String.format("bytes %d-%d/%d", startByte, endByte, fileLengthExpected)); contentLength = endByte + 1 - startByte; } else { // No range was requested, give back the whole file response.setStatus(HttpServletResponse.SC_OK); - contentLength = file.getFileSize(); + contentLength = fileLengthExpected; } response.setIntHeader("ETag", file.getId()); @@ -212,6 +210,10 @@ public class StreamController { return; } + if (fileLengthExpected != null) { + LOG.info("Streaming request for [{}] with range [{}]", file.getPath(), response.getHeader("Content-Range")); + } + // Terminate any other streams to this player. if (!isPodcast && !isSingleFile) { for (TransferStatus streamStatus : statusService.getStreamStatusesForPlayer(player)) { @@ -230,25 +232,37 @@ public class StreamController { ) { final int BUFFER_SIZE = 2048; byte[] buf = new byte[BUFFER_SIZE]; + long bytesWritten = 0; while (!status.terminated()) { if (player.getPlayQueue().getStatus() == PlayQueue.Status.STOPPED) { if (isPodcast || isSingleFile) { break; } else { - sendDummy(buf, out); + sendDummyDelayed(buf, out); } } else { int n = in.read(buf); if (n == -1) { if (isPodcast || isSingleFile) { + // Pad the output if needed to avoid content length errors on transcodes + if (fileLengthExpected != null && bytesWritten < fileLengthExpected) { + sendDummy(buf, out, fileLengthExpected - bytesWritten); + } break; } else { - sendDummy(buf, out); + sendDummyDelayed(buf, out); } } else { + if (fileLengthExpected != null && bytesWritten <= fileLengthExpected + && bytesWritten + n > fileLengthExpected) { + LOG.warn("Stream output exceeded expected length of {}. It is likely that " + + "the transcoder is not adhering to the bitrate limit or the media " + + "source is corrupted or has grown larger", fileLengthExpected); + } out.write(buf, 0, n); + bytesWritten += n; } } } @@ -321,30 +335,8 @@ public class StreamController { return null; } - private long getFileLength(TranscodingService.Parameters parameters) { - MediaFile file = parameters.getMediaFile(); - - if (!parameters.isDownsample() && !parameters.isTranscode()) { - return file.getFileSize(); - } - Integer duration = file.getDurationSeconds(); - Integer maxBitRate = parameters.getMaxBitRate(); - - if (duration == null) { - LOG.warn("Unknown duration for " + file + ". Unable to estimate transcoded size."); - return file.getFileSize(); - } - - if (maxBitRate == null) { - LOG.error("Unknown bit rate for " + file + ". Unable to estimate transcoded size."); - return file.getFileSize(); - } - - return duration * (long)maxBitRate * 1000L / 8L; - } - @Nullable - private HttpRange getRange(HttpServletRequest request, MediaFile file) { + private HttpRange getRange(HttpServletRequest request, Integer fileDuration, Long fileSize) { // First, look for "Range" HTTP header. HttpRange range = HttpRange.valueOf(request.getHeader("Range")); @@ -354,27 +346,25 @@ public class StreamController { // Second, look for "offsetSeconds" request parameter. String offsetSeconds = request.getParameter("offsetSeconds"); - range = parseAndConvertOffsetSeconds(offsetSeconds, file); + range = parseAndConvertOffsetSeconds(offsetSeconds, fileDuration, fileSize); return range; } @Nullable - private HttpRange parseAndConvertOffsetSeconds(String offsetSeconds, MediaFile file) { + private HttpRange parseAndConvertOffsetSeconds(String offsetSeconds, Integer fileDuration, Long fileSize) { if (offsetSeconds == null) { return null; } try { - Integer duration = file.getDurationSeconds(); - Long fileSize = file.getFileSize(); - if (duration == null || fileSize == null) { + if (fileDuration == null || fileSize == null) { return null; } float offset = Float.parseFloat(offsetSeconds); // Convert from time offset to byte offset. - long byteOffset = (long) (fileSize * (offset / duration)); + long byteOffset = (long) (fileSize * (offset / fileDuration)); return new HttpRange(byteOffset, null); } catch (Exception x) { @@ -455,17 +445,28 @@ public class StreamController { return size + (size % 2); } + private void sendDummy(byte[] buf, OutputStream out, long len) throws IOException { + long bytesWritten = 0; + int n; + + Arrays.fill(buf, (byte) 0xFF); + while (bytesWritten < len) { + n = (int) Math.min(buf.length, len - bytesWritten); + out.write(buf, 0, n); + bytesWritten += n; + } + } + /** * Feed the other end with some dummy data to keep it from reconnecting. */ - private void sendDummy(byte[] buf, OutputStream out) throws IOException { + private void sendDummyDelayed(byte[] buf, OutputStream out) throws IOException { try { Thread.sleep(2000); } catch (InterruptedException x) { LOG.warn("Interrupted in sleep.", x); } - Arrays.fill(buf, (byte) 0xFF); - out.write(buf); + sendDummy(buf, out, buf.length); out.flush(); } } diff --git a/airsonic-main/src/main/java/org/airsonic/player/service/TranscodingService.java b/airsonic-main/src/main/java/org/airsonic/player/service/TranscodingService.java index f7bf98dc..1037d5da 100644 --- a/airsonic-main/src/main/java/org/airsonic/player/service/TranscodingService.java +++ b/airsonic-main/src/main/java/org/airsonic/player/service/TranscodingService.java @@ -216,6 +216,8 @@ public class TranscodingService { } parameters.setMaxBitRate(maxBitRate); + parameters.setExpectedLength(getExpectedLength(parameters)); + parameters.setRangeAllowed(isRangeAllowed(parameters)); return parameters; } @@ -487,6 +489,57 @@ public class TranscodingService { return matches != null && matches.length > 0; } + /** + * Returns the length (or predicted/expected length) of a (possibly padded) media stream + */ + private Long getExpectedLength(Parameters parameters) { + MediaFile file = parameters.getMediaFile(); + + if (!parameters.isDownsample() && !parameters.isTranscode()) { + return file.getFileSize(); + } + Integer duration = file.getDurationSeconds(); + Integer maxBitRate = parameters.getMaxBitRate(); + + if (duration == null) { + LOG.warn("Unknown duration for " + file + ". Unable to estimate transcoded size."); + return null; + } + + if (maxBitRate == null) { + LOG.error("Unknown bit rate for " + file + ". Unable to estimate transcoded size."); + return null; + } + + // Over-estimate size a bit (2 seconds) so don't cut off early in case of small calculation differences + return (duration + 2) * (long)maxBitRate * 1000L / 8L; + } + + private boolean isRangeAllowed(Parameters parameters) { + Transcoding transcoding = parameters.getTranscoding(); + List steps = Arrays.asList(); + if (transcoding != null) { + steps = Arrays.asList(transcoding.getStep3(), transcoding.getStep2(), transcoding.getStep1()); + } else if (parameters.isDownsample()) { + steps = Arrays.asList(settingsService.getDownsamplingCommand()); + } else { + return true; // neither transcoding nor downsampling + } + + // Verify that were able to predict the length + if (parameters.getExpectedLength() == null) { + return false; + } + + // Check if last configured step uses the bitrate, if so, range should be pretty safe + for (String step : steps) { + if (step != null) { + return step.contains("%b"); + } + } + return false; + } + /** * Returns the directory in which all transcoders are installed. */ @@ -517,6 +570,8 @@ public class TranscodingService { public static class Parameters { private boolean downsample; + private Long expectedLength; + private boolean rangeAllowed; private final MediaFile mediaFile; private final VideoTranscodingSettings videoTranscodingSettings; private Integer maxBitRate; @@ -543,6 +598,22 @@ public class TranscodingService { return transcoding != null; } + public boolean isRangeAllowed() { + return this.rangeAllowed; + } + + public void setRangeAllowed(boolean rangeAllowed) { + this.rangeAllowed = rangeAllowed; + } + + public Long getExpectedLength() { + return this.expectedLength; + } + + public void setExpectedLength(Long expectedLength) { + this.expectedLength = expectedLength; + } + public void setTranscoding(Transcoding transcoding) { this.transcoding = transcoding; } diff --git a/integration-test/src/test/resources/blobs/stream/dance/responses/1.dat b/integration-test/src/test/resources/blobs/stream/dance/responses/1.dat index 6a89b6bf..2a31737d 100644 --- a/integration-test/src/test/resources/blobs/stream/dance/responses/1.dat +++ b/integration-test/src/test/resources/blobs/stream/dance/responses/1.dat @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d197053e46e2ddf49f0230885d6fbc224a9d634be6462d965be256729e7fbdd2 -size 10238118 +oid sha256:608519910657cb59f64d904605794f9df97bf92a725d420f002548f825ec4524 +size 10272000 diff --git a/integration-test/src/test/resources/blobs/stream/dead/responses/1.dat b/integration-test/src/test/resources/blobs/stream/dead/responses/1.dat index 48eafcf2..be66a977 100644 --- a/integration-test/src/test/resources/blobs/stream/dead/responses/1.dat +++ b/integration-test/src/test/resources/blobs/stream/dead/responses/1.dat @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:685a5981d4098ff310c6a9b15587cbcb3878194014699d7f9b60013385ef9aed -size 5951995 +oid sha256:6e67621051eba3527bed4d5a8b30cf676089837feaf1ce6919ce84068feb5789 +size 6000000 diff --git a/integration-test/src/test/resources/features/api/stream-flac.feature b/integration-test/src/test/resources/features/api/stream-flac-as-mp3.feature similarity index 87% rename from integration-test/src/test/resources/features/api/stream-flac.feature rename to integration-test/src/test/resources/features/api/stream-flac-as-mp3.feature index 1045915b..6b690c95 100644 --- a/integration-test/src/test/resources/features/api/stream-flac.feature +++ b/integration-test/src/test/resources/features/api/stream-flac-as-mp3.feature @@ -9,5 +9,5 @@ Feature: Stream API for FLAC When A stream is consumed Then Print debug output Then The response bytes are equal - Then The length headers are absent + Then The length headers are correct diff --git a/integration-test/src/test/resources/features/api/stream-m4a-vbr.feature b/integration-test/src/test/resources/features/api/stream-m4a-as-mp3.feature similarity index 87% rename from integration-test/src/test/resources/features/api/stream-m4a-vbr.feature rename to integration-test/src/test/resources/features/api/stream-m4a-as-mp3.feature index 2c863ad0..5b5149f2 100644 --- a/integration-test/src/test/resources/features/api/stream-m4a-vbr.feature +++ b/integration-test/src/test/resources/features/api/stream-m4a-as-mp3.feature @@ -9,5 +9,5 @@ Feature: Stream API for VBR M4A When A stream is consumed Then Print debug output Then The response bytes are equal - Then The length headers are absent + Then The length headers are correct