Change back to allow transcodes to allow seeking

This switches back to allowing range rather than chunked requests so that
seeking works in the web player, but does so by safer means than previous
solutions, by slightly over-estimating the transcoded size, then sending
dummy bytes to the client to fill any gap.  Fixes #1117.  Addresses #685.
master
Evan Harris 6 years ago
parent 2f75254010
commit e691c80774
No known key found for this signature in database
GPG Key ID: FF3BD4DA59FF9EDC
  1. 62
      airsonic-main/src/main/java/org/airsonic/player/controller/StreamController.java

@ -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; video is always assumed to transcode
if (isConversion || file.isVideo()) { if (file.isVideo()) {
// 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 // Not transcoding, partial content permitted because we know 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,31 @@ 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 {
out.write(buf, 0, n); out.write(buf, 0, n);
bytesWritten += n;
} }
} }
} }
@ -340,11 +348,12 @@ public class StreamController {
return file.getFileSize(); return file.getFileSize();
} }
return duration * (long)maxBitRate * 1000L / 8L; // 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;
} }
@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 +363,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 +462,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();
} }
} }

Loading…
Cancel
Save