@ -41,6 +41,7 @@ import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.RequestMapping ;
import org.springframework.web.bind.annotation.RequestMapping ;
import org.springframework.web.bind.annotation.RequestMethod ;
import org.springframework.web.bind.annotation.RequestMethod ;
import javax.annotation.Nullable ;
import javax.servlet.http.HttpServletRequest ;
import javax.servlet.http.HttpServletRequest ;
import javax.servlet.http.HttpServletResponse ;
import javax.servlet.http.HttpServletResponse ;
@ -52,14 +53,13 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern ;
import java.util.regex.Pattern ;
/ * *
/ * *
* A controller which streams the content of a { @link PlayQueue } to a remote
* A controller which streams the content of a { @link PlayQueue } to a remote { @link Player } .
* { @link Player } .
*
*
* @author Sindre Mehus
* @author Sindre Mehus
* /
* /
@Controller
@Controller
@RequestMapping ( value = { "/stream/**" , "/ext/stream/**" } )
@RequestMapping ( value = { "/stream/**" , "/ext/stream/**" } )
public class StreamController {
public class StreamController {
private static final Logger LOG = LoggerFactory . getLogger ( StreamController . class ) ;
private static final Logger LOG = LoggerFactory . getLogger ( StreamController . class ) ;
@ -94,7 +94,8 @@ public class StreamController {
try {
try {
if ( ! ( authentication instanceof JWTAuthenticationToken ) & & ! user . isStreamRole ( ) ) {
if ( ! ( authentication instanceof JWTAuthenticationToken ) & & ! user . isStreamRole ( ) ) {
response . sendError ( HttpServletResponse . SC_FORBIDDEN , "Streaming is forbidden for user " + user . getUsername ( ) ) ;
response . sendError ( HttpServletResponse . SC_FORBIDDEN ,
"Streaming is forbidden for user " + user . getUsername ( ) ) ;
return ;
return ;
}
}
@ -132,9 +133,10 @@ public class StreamController {
if ( isSingleFile ) {
if ( isSingleFile ) {
if ( ! ( authentication instanceof JWTAuthenticationToken ) & & ! securityService . isFolderAccessAllowed ( file , user . getUsername ( ) ) ) {
if ( ! ( authentication instanceof JWTAuthenticationToken ) & & ! securityService . isFolderAccessAllowed ( file ,
user . getUsername ( ) ) ) {
response . sendError ( HttpServletResponse . SC_FORBIDDEN ,
response . sendError ( HttpServletResponse . SC_FORBIDDEN ,
"Access to file " + file . getId ( ) + " is forbidden for user " + user . getUsername ( ) ) ;
"Access to file " + file . getId ( ) + " is forbidden for user " + user . getUsername ( ) ) ;
return ;
return ;
}
}
@ -151,28 +153,48 @@ public class StreamController {
playQueue . addFiles ( true , file ) ;
playQueue . addFiles ( true , file ) ;
player . setPlayQueue ( playQueue ) ;
player . setPlayQueue ( playQueue ) ;
if ( settingsService . isEnableSeek ( ) & & ! file . isVideo ( ) ) {
TranscodingService . Parameters parameters = transcodingService . getParameters ( file , player , maxBitRate ,
response . setIntHeader ( "ETag" , file . getId ( ) ) ;
preferredTargetFormat , null ) ;
response . setHeader ( "Accept-Ranges" , "bytes" ) ;
}
TranscodingService . Parameters parameters = transcodingService . getParameters ( file , player , maxBitRate , preferredTargetFormat , null ) ;
long fileLength = getFileLength ( parameters ) ;
boolean isConversion = parameters . isDownsample ( ) | | parameters . isTranscode ( ) ;
boolean isConversion = parameters . isDownsample ( ) | | parameters . isTranscode ( ) ;
boolean estimateContentLength = ServletRequestUtils . getBooleanParameter ( request , "estimateContentLength" , false ) ;
boolean estimateContentLength = ServletRequestUtils . getBooleanParameter ( request ,
"estimateContentLength" , false ) ;
boolean isHls = ServletRequestUtils . getBooleanParameter ( request , "hls" , false ) ;
boolean isHls = ServletRequestUtils . getBooleanParameter ( request , "hls" , false ) ;
range = getRange ( request , file ) ;
// Wrangle response length and ranges.
if ( settingsService . isEnableSeek ( ) & & range ! = null & & ! file . isVideo ( ) ) {
//
LOG . info ( "{}: Got HTTP range: {}" , request . getRemoteAddr ( ) , range ) ;
// Support ranges as long as we're not transcoding; video is always assumed to transcode
response . setStatus ( HttpServletResponse . SC_PARTIAL_CONTENT ) ;
if ( isConversion | | file . isVideo ( ) ) {
Util . setContentLength ( response , range . isClosed ( ) ? range . size ( ) : fileLength - range . getFirstBytePos ( ) ) ;
// Use chunked transfer; do not accept range requests
long lastBytePos = range . getLastBytePos ( ) ! = null ? range . getLastBytePos ( ) : fileLength - 1 ;
response . setStatus ( HttpServletResponse . SC_OK ) ;
response . setHeader ( "Content-Range" , "bytes " + range . getFirstBytePos ( ) + "-" + lastBytePos + "/" + fileLength ) ;
response . setHeader ( "Accept-Ranges" , "none" ) ;
} else if ( ! isHls & & ( ! isConversion | | estimateContentLength ) ) {
} else {
Util . setContentLength ( response , fileLength ) ;
// Not transcoding, partial content permitted because we know the final size
long contentLength ;
// If range was requested, respond in kind
range = getRange ( request , file ) ;
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 ;
response . setHeader ( "Content-Range" ,
String . format ( "bytes %d-%d/%d" , startByte , endByte , file . getFileSize ( ) ) ) ;
contentLength = endByte + 1 - startByte ;
} else {
// No range was requested, give back the whole file
response . setStatus ( HttpServletResponse . SC_OK ) ;
contentLength = file . getFileSize ( ) ;
}
response . setIntHeader ( "ETag" , file . getId ( ) ) ;
Util . setContentLength ( response , contentLength ) ;
}
}
// Set content type of response
if ( isHls ) {
if ( isHls ) {
response . setContentType ( StringUtil . getMimeType ( "ts" ) ) ; // HLS is always MPEG TS.
response . setContentType ( StringUtil . getMimeType ( "ts" ) ) ; // HLS is always MPEG TS.
} else {
} else {
@ -187,6 +209,7 @@ public class StreamController {
}
}
}
}
// All headers are set, stop if that's all the client requested.
if ( request . getMethod ( ) . equals ( "HEAD" ) ) {
if ( request . getMethod ( ) . equals ( "HEAD" ) ) {
return ;
return ;
}
}
@ -202,49 +225,32 @@ public class StreamController {
status = statusService . createStreamStatus ( player ) ;
status = statusService . createStreamStatus ( player ) ;
in = new PlayQueueInputStream ( player , status , maxBitRate , preferredTargetFormat , videoTranscodingSettings , transcodingService ,
in = new PlayQueueInputStream ( player , status , maxBitRate , preferredTargetFormat , videoTranscodingSettings ,
audioScrobblerService , mediaFileService , searchService ) ;
transcodingService , audioScrobblerService , mediaFileService , searchService ) ;
OutputStream out = RangeOutputStream . wrap ( response . getOutputStream ( ) , range ) ;
// Enabled SHOUTcast, if requested.
boolean isShoutCastRequested = "1" . equals ( request . getHeader ( "icy-metadata" ) ) ;
if ( isShoutCastRequested & & ! isSingleFile ) {
response . setHeader ( "icy-metaint" , "" + ShoutCastOutputStream . META_DATA_INTERVAL ) ;
response . setHeader ( "icy-notice1" , "This stream is served using Airsonic" ) ;
response . setHeader ( "icy-notice2" , "Airsonic - Free media streamer" ) ;
response . setHeader ( "icy-name" , "Airsonic" ) ;
response . setHeader ( "icy-genre" , "Mixed" ) ;
response . setHeader ( "icy-url" , "https://airsonic.github.io/" ) ;
out = new ShoutCastOutputStream ( out , player . getPlayQueue ( ) , settingsService ) ;
}
final int BUFFER_SIZE = 2048 ;
byte [ ] buf = new byte [ BUFFER_SIZE ] ;
while ( true ) {
try ( OutputStream out = makeOutputStream ( request , response , range , isSingleFile , player , settingsService ) ) {
final int BUFFER_SIZE = 2048 ;
byte [ ] buf = new byte [ BUFFER_SIZE ] ;
// Check if stream has been terminated.
while ( ! status . terminated ( ) ) {
if ( status . terminated ( ) ) {
if ( player . getPlayQueue ( ) . getStatus ( ) = = PlayQueue . Status . STOPPED ) {
return ;
}
if ( player . getPlayQueue ( ) . getStatus ( ) = = PlayQueue . Status . STOPPED ) {
if ( isPodcast | | isSingleFile ) {
break ;
} else {
sendDummy ( buf , out ) ;
}
} else {
int n = in . read ( buf ) ;
if ( n = = - 1 ) {
if ( isPodcast | | isSingleFile ) {
if ( isPodcast | | isSingleFile ) {
break ;
break ;
} else {
} else {
sendDummy ( buf , out ) ;
sendDummy ( buf , out ) ;
}
}
} else {
} else {
out . write ( buf , 0 , n ) ;
int n = in . read ( buf ) ;
if ( n = = - 1 ) {
if ( isPodcast | | isSingleFile ) {
break ;
} else {
sendDummy ( buf , out ) ;
}
} else {
out . write ( buf , 0 , n ) ;
}
}
}
}
}
}
}
@ -257,7 +263,10 @@ public class StreamController {
shouldCatch | = Util . isInstanceOfClassName ( e , "org.apache.catalina.connector.ClientAbortException" ) ;
shouldCatch | = Util . isInstanceOfClassName ( e , "org.apache.catalina.connector.ClientAbortException" ) ;
shouldCatch | = Util . isInstanceOfClassName ( e , "org.eclipse.jetty.io.EofException" ) ;
shouldCatch | = Util . isInstanceOfClassName ( e , "org.eclipse.jetty.io.EofException" ) ;
if ( shouldCatch ) {
if ( shouldCatch ) {
LOG . info ( "{}: Client unexpectedly closed connection while loading {} ({})" , request . getRemoteAddr ( ) , Util . getAnonymizedURLForRequest ( request ) , e . getCause ( ) . toString ( ) ) ;
LOG . info ( "{}: Client unexpectedly closed connection while loading {} ({})" ,
request . getRemoteAddr ( ) ,
Util . getAnonymizedURLForRequest ( request ) ,
e . getCause ( ) . toString ( ) ) ;
return ;
return ;
}
}
@ -274,6 +283,31 @@ public class StreamController {
return ;
return ;
}
}
/ * *
* Construct an appropriate output stream based on the request .
* < p >
* This is responsible for limiting the output to the given range ( if not null ) and injecting Shoutcast metadata
* into the stream if requested .
* /
private OutputStream makeOutputStream ( HttpServletRequest request , HttpServletResponse response , HttpRange range ,
boolean isSingleFile , Player player , SettingsService settingsService )
throws IOException {
OutputStream out = RangeOutputStream . wrap ( response . getOutputStream ( ) , range ) ;
// Enabled SHOUTcast, if requested.
boolean isShoutCastRequested = "1" . equals ( request . getHeader ( "icy-metadata" ) ) ;
if ( isShoutCastRequested & & ! isSingleFile ) {
response . setHeader ( "icy-metaint" , "" + ShoutCastOutputStream . META_DATA_INTERVAL ) ;
response . setHeader ( "icy-notice1" , "This stream is served using Airsonic" ) ;
response . setHeader ( "icy-notice2" , "Airsonic - Free media streamer" ) ;
response . setHeader ( "icy-name" , "Airsonic" ) ;
response . setHeader ( "icy-genre" , "Mixed" ) ;
response . setHeader ( "icy-url" , "https://airsonic.github.io/" ) ;
out = new ShoutCastOutputStream ( out , player . getPlayQueue ( ) , settingsService ) ;
}
return out ;
}
private void setContentDuration ( HttpServletResponse response , MediaFile file ) {
private void setContentDuration ( HttpServletResponse response , MediaFile file ) {
if ( file . getDurationSeconds ( ) ! = null ) {
if ( file . getDurationSeconds ( ) ! = null ) {
response . setHeader ( "X-Content-Duration" , String . format ( "%.1f" , file . getDurationSeconds ( ) . doubleValue ( ) ) ) ;
response . setHeader ( "X-Content-Duration" , String . format ( "%.1f" , file . getDurationSeconds ( ) . doubleValue ( ) ) ) ;
@ -314,6 +348,7 @@ public class StreamController {
return duration * ( long ) maxBitRate * 1000L / 8L ;
return duration * ( long ) maxBitRate * 1000L / 8L ;
}
}
@Nullable
private HttpRange getRange ( HttpServletRequest request , MediaFile file ) {
private HttpRange getRange ( HttpServletRequest request , MediaFile file ) {
// First, look for "Range" HTTP header.
// First, look for "Range" HTTP header.
@ -325,13 +360,11 @@ 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 , file ) ;
if ( range ! = null ) {
return range ;
return range ;
}
return null ;
}
}
@Nullable
private HttpRange parseAndConvertOffsetSeconds ( String offsetSeconds , MediaFile file ) {
private HttpRange parseAndConvertOffsetSeconds ( String offsetSeconds , MediaFile file ) {
if ( offsetSeconds = = null ) {
if ( offsetSeconds = = null ) {
return null ;
return null ;
@ -355,12 +388,14 @@ public class StreamController {
}
}
}
}
private VideoTranscodingSettings createVideoTranscodingSettings ( MediaFile file , HttpServletRequest request ) throws ServletRequestBindingException {
private VideoTranscodingSettings createVideoTranscodingSettings ( MediaFile file , HttpServletRequest request )
throws ServletRequestBindingException {
Integer existingWidth = file . getWidth ( ) ;
Integer existingWidth = file . getWidth ( ) ;
Integer existingHeight = file . getHeight ( ) ;
Integer existingHeight = file . getHeight ( ) ;
Integer maxBitRate = ServletRequestUtils . getIntParameter ( request , "maxBitRate" ) ;
Integer maxBitRate = ServletRequestUtils . getIntParameter ( request , "maxBitRate" ) ;
int timeOffset = ServletRequestUtils . getIntParameter ( request , "timeOffset" , 0 ) ;
int timeOffset = ServletRequestUtils . getIntParameter ( request , "timeOffset" , 0 ) ;
int defaultDuration = file . getDurationSeconds ( ) = = null ? Integer . MAX_VALUE : file . getDurationSeconds ( ) - timeOffset ;
int defaultDuration = file . getDurationSeconds ( ) = = null ? Integer . MAX_VALUE :
file . getDurationSeconds ( ) - timeOffset ;
int duration = ServletRequestUtils . getIntParameter ( request , "duration" , defaultDuration ) ;
int duration = ServletRequestUtils . getIntParameter ( request , "duration" , defaultDuration ) ;
boolean hls = ServletRequestUtils . getBooleanParameter ( request , "hls" , false ) ;
boolean hls = ServletRequestUtils . getBooleanParameter ( request , "hls" , false ) ;