Merge pull request #1123 from eharris/seekable-transcodes

Change back to allow transcodes to allow seeking
master
François-Xavier Thomas 4 years ago committed by GitHub
commit 70de4c8f25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 91
      airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java
  2. 71
      airsonic-main/src/main/java/org/airsonic/player/service/TranscodingService.java
  3. 4
      integration-test/src/test/resources/blobs/stream/dance/responses/1.dat
  4. 4
      integration-test/src/test/resources/blobs/stream/dead/responses/1.dat
  5. 2
      integration-test/src/test/resources/features/api/stream-flac-as-mp3.feature
  6. 2
      integration-test/src/test/resources/features/api/stream-m4a-as-mp3.feature

@ -128,6 +128,7 @@ public class StreamController {
MediaFile file = getSingleFile(request); MediaFile file = getSingleFile(request);
boolean isSingleFile = file != null; boolean isSingleFile = file != null;
HttpRange range = null; HttpRange range = null;
Long fileLengthExpected = null;
if (isSingleFile) { if (isSingleFile) {
@ -153,39 +154,36 @@ public class StreamController {
TranscodingService.Parameters parameters = transcodingService.getParameters(file, player, maxBitRate, TranscodingService.Parameters parameters = transcodingService.getParameters(file, player, maxBitRate,
preferredTargetFormat, null); preferredTargetFormat, null);
boolean isConversion = parameters.isDownsample() || parameters.isTranscode();
boolean estimateContentLength = ServletRequestUtils.getBooleanParameter(request,
"estimateContentLength", false);
boolean isHls = ServletRequestUtils.getBooleanParameter(request, "hls", false); boolean isHls = ServletRequestUtils.getBooleanParameter(request, "hls", false);
fileLengthExpected = parameters.getExpectedLength();
// Wrangle response length and ranges. // Wrangle response length and ranges.
// //
// Support ranges as long as we're not transcoding; video is always assumed to transcode // Support ranges as long as we're not transcoding blindly; video is always assumed to transcode
if (isConversion || file.isVideo()) { if (file.isVideo() || ! parameters.isRangeAllowed()) {
// Use chunked transfer; do not accept range requests // Use chunked transfer; do not accept range requests
response.setStatus(HttpServletResponse.SC_OK); response.setStatus(HttpServletResponse.SC_OK);
response.setHeader("Accept-Ranges", "none"); response.setHeader("Accept-Ranges", "none");
} else { } 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; long contentLength;
// If range was requested, respond in kind // If range was requested, respond in kind
range = getRange(request, file); range = getRange(request, file.getDurationSeconds(), fileLengthExpected);
if (range != null) { if (range != null) {
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader("Accept-Ranges", "bytes"); response.setHeader("Accept-Ranges", "bytes");
// Both ends are inclusive // Both ends are inclusive
long startByte = range.getFirstBytePos(); 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", 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; contentLength = endByte + 1 - startByte;
} else { } else {
// No range was requested, give back the whole file // No range was requested, give back the whole file
response.setStatus(HttpServletResponse.SC_OK); response.setStatus(HttpServletResponse.SC_OK);
contentLength = file.getFileSize(); contentLength = fileLengthExpected;
} }
response.setIntHeader("ETag", file.getId()); response.setIntHeader("ETag", file.getId());
@ -212,6 +210,10 @@ public class StreamController {
return; return;
} }
if (fileLengthExpected != null) {
LOG.info("Streaming request for [{}] with range [{}]", file.getPath(), response.getHeader("Content-Range"));
}
// Terminate any other streams to this player. // Terminate any other streams to this player.
if (!isPodcast && !isSingleFile) { if (!isPodcast && !isSingleFile) {
for (TransferStatus streamStatus : statusService.getStreamStatusesForPlayer(player)) { for (TransferStatus streamStatus : statusService.getStreamStatusesForPlayer(player)) {
@ -230,25 +232,37 @@ public class StreamController {
) { ) {
final int BUFFER_SIZE = 2048; final int BUFFER_SIZE = 2048;
byte[] buf = new byte[BUFFER_SIZE]; byte[] buf = new byte[BUFFER_SIZE];
long bytesWritten = 0;
while (!status.terminated()) { while (!status.terminated()) {
if (player.getPlayQueue().getStatus() == PlayQueue.Status.STOPPED) { if (player.getPlayQueue().getStatus() == PlayQueue.Status.STOPPED) {
if (isPodcast || isSingleFile) { if (isPodcast || isSingleFile) {
break; break;
} else { } else {
sendDummy(buf, out); sendDummyDelayed(buf, out);
} }
} else { } else {
int n = in.read(buf); int n = in.read(buf);
if (n == -1) { if (n == -1) {
if (isPodcast || isSingleFile) { 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; break;
} else { } else {
sendDummy(buf, out); sendDummyDelayed(buf, out);
} }
} else { } 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); out.write(buf, 0, n);
bytesWritten += n;
} }
} }
} }
@ -321,30 +335,8 @@ public class StreamController {
return null; 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 @Nullable
private HttpRange getRange(HttpServletRequest request, MediaFile file) { private HttpRange getRange(HttpServletRequest request, Integer fileDuration, Long fileSize) {
// First, look for "Range" HTTP header. // First, look for "Range" HTTP header.
HttpRange range = HttpRange.valueOf(request.getHeader("Range")); HttpRange range = HttpRange.valueOf(request.getHeader("Range"));
@ -354,27 +346,25 @@ public class StreamController {
// Second, look for "offsetSeconds" request parameter. // Second, look for "offsetSeconds" request parameter.
String offsetSeconds = request.getParameter("offsetSeconds"); String offsetSeconds = request.getParameter("offsetSeconds");
range = parseAndConvertOffsetSeconds(offsetSeconds, file); range = parseAndConvertOffsetSeconds(offsetSeconds, fileDuration, fileSize);
return range; return range;
} }
@Nullable @Nullable
private HttpRange parseAndConvertOffsetSeconds(String offsetSeconds, MediaFile file) { private HttpRange parseAndConvertOffsetSeconds(String offsetSeconds, Integer fileDuration, Long fileSize) {
if (offsetSeconds == null) { if (offsetSeconds == null) {
return null; return null;
} }
try { try {
Integer duration = file.getDurationSeconds(); if (fileDuration == null || fileSize == null) {
Long fileSize = file.getFileSize();
if (duration == null || fileSize == null) {
return null; return null;
} }
float offset = Float.parseFloat(offsetSeconds); float offset = Float.parseFloat(offsetSeconds);
// Convert from time offset to byte offset. // Convert from time offset to byte offset.
long byteOffset = (long) (fileSize * (offset / duration)); long byteOffset = (long) (fileSize * (offset / fileDuration));
return new HttpRange(byteOffset, null); return new HttpRange(byteOffset, null);
} catch (Exception x) { } catch (Exception x) {
@ -455,17 +445,28 @@ public class StreamController {
return size + (size % 2); 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. * 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 { try {
Thread.sleep(2000); Thread.sleep(2000);
} catch (InterruptedException x) { } catch (InterruptedException x) {
LOG.warn("Interrupted in sleep.", x); LOG.warn("Interrupted in sleep.", x);
} }
Arrays.fill(buf, (byte) 0xFF); sendDummy(buf, out, buf.length);
out.write(buf);
out.flush(); out.flush();
} }
} }

@ -216,6 +216,8 @@ public class TranscodingService {
} }
parameters.setMaxBitRate(maxBitRate); parameters.setMaxBitRate(maxBitRate);
parameters.setExpectedLength(getExpectedLength(parameters));
parameters.setRangeAllowed(isRangeAllowed(parameters));
return parameters; return parameters;
} }
@ -487,6 +489,57 @@ public class TranscodingService {
return matches != null && matches.length > 0; 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<String> 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. * Returns the directory in which all transcoders are installed.
*/ */
@ -517,6 +570,8 @@ public class TranscodingService {
public static class Parameters { public static class Parameters {
private boolean downsample; private boolean downsample;
private Long expectedLength;
private boolean rangeAllowed;
private final MediaFile mediaFile; private final MediaFile mediaFile;
private final VideoTranscodingSettings videoTranscodingSettings; private final VideoTranscodingSettings videoTranscodingSettings;
private Integer maxBitRate; private Integer maxBitRate;
@ -543,6 +598,22 @@ public class TranscodingService {
return transcoding != null; 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) { public void setTranscoding(Transcoding transcoding) {
this.transcoding = transcoding; this.transcoding = transcoding;
} }

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d197053e46e2ddf49f0230885d6fbc224a9d634be6462d965be256729e7fbdd2 oid sha256:608519910657cb59f64d904605794f9df97bf92a725d420f002548f825ec4524
size 10238118 size 10272000

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:685a5981d4098ff310c6a9b15587cbcb3878194014699d7f9b60013385ef9aed oid sha256:6e67621051eba3527bed4d5a8b30cf676089837feaf1ce6919ce84068feb5789
size 5951995 size 6000000

@ -9,5 +9,5 @@ Feature: Stream API for FLAC
When A stream is consumed When A stream is consumed
Then Print debug output Then Print debug output
Then The response bytes are equal Then The response bytes are equal
Then The length headers are absent Then The length headers are correct

@ -9,5 +9,5 @@ Feature: Stream API for VBR M4A
When A stream is consumed When A stream is consumed
Then Print debug output Then Print debug output
Then The response bytes are equal Then The response bytes are equal
Then The length headers are absent Then The length headers are correct
Loading…
Cancel
Save