/******************************************************************************* * sdrtrunk * Copyright (C) 2014-2016 Dennis Sheirer * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/> * ******************************************************************************/ package audio.broadcast.icecast; import audio.broadcast.BroadcastState; import audio.convert.MP3AudioConverter; import org.apache.mina.core.RuntimeIoException; import org.apache.mina.core.buffer.IoBuffer; import org.apache.mina.core.future.ConnectFuture; import org.apache.mina.core.service.IoHandlerAdapter; import org.apache.mina.core.session.IoSession; import org.apache.mina.filter.codec.ProtocolDecoderException; import org.apache.mina.http.HttpClientCodec; import org.apache.mina.http.HttpException; import org.apache.mina.http.HttpRequestImpl; import org.apache.mina.http.api.DefaultHttpResponse; import org.apache.mina.http.api.HttpMethod; import org.apache.mina.http.api.HttpStatus; import org.apache.mina.http.api.HttpVersion; import org.apache.mina.transport.socket.nio.NioSocketConnector; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import properties.SystemProperties; import util.ThreadPool; import java.net.ConnectException; import java.net.InetSocketAddress; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public class IcecastHTTPAudioBroadcaster extends IcecastAudioBroadcaster { private static final Logger mLog = LoggerFactory.getLogger(IcecastHTTPAudioBroadcaster.class); private static final long RECONNECT_INTERVAL_MILLISECONDS = 30000; //30 seconds private NioSocketConnector mSocketConnector; private IoSession mStreamingSession = null; private Map<String,String> mHTTPHeaders; private long mLastConnectionAttempt = 0; private AtomicBoolean mConnecting = new AtomicBoolean(); /** * Creates an Icecast 2.4.x compatible broadcaster using HTTP 1.1 protocol. This broadcaster is * compatible with Icecast version 2.4.x and newer versions of the server software. * * Note: use @see IcecastTCPAudioBroadcaster for Icecast version 2.3.x and older. * * This broadcaster uses the Apache Mina library for the streaming socket connection and for metadata updates. The * IcecastHTTPIOHandler manages all interaction with the Icecast server and manages the overall broadcast state. * * @param configuration for the Icecast stream */ public IcecastHTTPAudioBroadcaster(IcecastHTTPConfiguration configuration) { super(configuration); } /** * Broadcasts the audio frame or sequence */ @Override protected void broadcastAudio(byte[] audio) { if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) { IoBuffer buffer = IoBuffer.allocate(audio.length); buffer.put(audio); buffer.flip(); mStreamingSession.write(buffer); } } /** * (Re)Connects the broadcaster to the remote server if it currently is disconnected and indicates if the broadcaster * is currently connected to the remote server following any connection attempts. * * Attempts to connect via this method when the broadcast state indicates an error condition will be ignored. * * @return true if the audio handler can stream audio */ private boolean connect() { if(!connected() && canConnect() && (mLastConnectionAttempt + RECONNECT_INTERVAL_MILLISECONDS < System.currentTimeMillis()) && mConnecting.compareAndSet(false, true)) { mLastConnectionAttempt = System.currentTimeMillis(); if(mSocketConnector == null) { mSocketConnector = new NioSocketConnector(); mSocketConnector.setConnectTimeoutCheckInterval(10000); // mSocketConnector.getFilterChain().addLast("logger", // new LoggingFilter(IcecastTCPAudioBroadcaster.class)); mSocketConnector.getFilterChain().addLast("codec", new HttpClientCodec()); mSocketConnector.setHandler(new IcecastHTTPIOHandler()); } mStreamingSession = null; Runnable runnable = new Runnable() { @Override public void run() { setBroadcastState(BroadcastState.CONNECTING); try { ConnectFuture future = mSocketConnector .connect(new InetSocketAddress(getBroadcastConfiguration().getHost(), getBroadcastConfiguration().getPort())); future.awaitUninterruptibly(); mStreamingSession = future.getSession(); } catch(RuntimeIoException rie) { Throwable throwableCause = rie.getCause(); if(throwableCause instanceof ConnectException) { setBroadcastState(BroadcastState.NO_SERVER); } else if(throwableCause != null) { setBroadcastState(BroadcastState.ERROR); mLog.debug("Failed to connect", rie); } else { setBroadcastState(BroadcastState.ERROR); mLog.debug("Failed to connect - no exception is available"); } disconnect(); } mConnecting.set(false); } }; ThreadPool.SCHEDULED.schedule(runnable, 0l, TimeUnit.SECONDS); } return connected(); } /** * Disconnect from the remote broadcast server and cleanup input/output streams and socket connection */ public void disconnect() { if(mStreamingSession != null) { mStreamingSession.closeNow(); } } /** * IO Handler for managing Icecast HTTP connection and credentials */ public class IcecastHTTPIOHandler extends IoHandlerAdapter { @Override public void sessionOpened(IoSession session) throws Exception { //Send stream configuration and user credentials upon connecting to remote server HttpRequestImpl request = new HttpRequestImpl(HttpVersion.HTTP_1_1, HttpMethod.PUT, getConfiguration().getMountPoint(), "", getHTTPHeaders()); session.write(request); } private Map<String,String> getHTTPHeaders() { if(mHTTPHeaders == null) { mHTTPHeaders = new HashMap<>(); mHTTPHeaders.put(IcecastHeader.ACCEPT.getValue(), "*/*"); mHTTPHeaders.put(IcecastHeader.CONTENT_TYPE.getValue(), getConfiguration().getBroadcastFormat().getValue()); mHTTPHeaders.put(IcecastHeader.USER_AGENT.getValue(), SystemProperties.getInstance().getApplicationName()); mHTTPHeaders.put(IcecastHeader.AUTHORIZATION.getValue(), getConfiguration().getBase64EncodedCredentials()); mHTTPHeaders.put(IcecastHeader.PUBLIC.getValue(), getConfiguration().isPublic() ? "1" : "0"); StringBuilder sb = new StringBuilder(); sb.append("samplerate=").append(getConfiguration().getSampleRate()).append(";"); sb.append("quality=").append(MP3AudioConverter.AUDIO_QUALITY).append(";"); sb.append("channels=").append(getConfiguration().getChannels()); mHTTPHeaders.put(IcecastHeader.AUDIO_INFO.getValue(), sb.toString()); if(getConfiguration().hasName()) { mHTTPHeaders.put(IcecastHeader.NAME.getValue(), getConfiguration().getName()); } if(getConfiguration().hasDescription()) { mHTTPHeaders.put(IcecastHeader.DESCRIPTION.getValue(), getConfiguration().getDescription()); } if(getConfiguration().hasURL()) { mHTTPHeaders.put(IcecastHeader.URL.getValue(), getConfiguration().getURL()); } if(getConfiguration().hasGenre()) { mHTTPHeaders.put(IcecastHeader.GENRE.getValue(), getConfiguration().getGenre()); } if(getConfiguration().hasBitRate()) { mHTTPHeaders.put(IcecastHeader.BITRATE.getValue(), String.valueOf(getConfiguration().getBitRate())); } } return mHTTPHeaders; } @Override public void sessionClosed(IoSession session) throws Exception { //If there is already an error state, don't override it. Otherwise, set state to disconnected if(!getBroadcastState().isErrorState()) { setBroadcastState(BroadcastState.DISCONNECTED); } mSocketConnector.dispose(); mStreamingSession = null; mSocketConnector = null; mConnecting.set(false); super.sessionClosed(session); } @Override public void exceptionCaught(IoSession session, Throwable throwable) throws Exception { if(throwable instanceof ProtocolDecoderException) { Throwable cause = ((ProtocolDecoderException) throwable).getCause(); if(cause instanceof HttpException && ((HttpException) cause).getStatusCode() == HttpStatus.CLIENT_ERROR_LENGTH_REQUIRED.code()) { //Ignore - Icecast 2.4 sometimes doesn't send any headers with their HTTP responses. Mina expects a //content-length for HTTP responses. } else { mLog.error("HTTP protocol decoder error", throwable); setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR); disconnect(); } } else { mLog.error("Broadcast error", throwable); setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR); disconnect(); } mConnecting.set(false); } @Override public void messageReceived(IoSession session, Object object) throws Exception { if(object instanceof DefaultHttpResponse) { DefaultHttpResponse response = (DefaultHttpResponse) object; switch(response.getStatus()) { case INFORMATIONAL_CONTINUE: break; case SUCCESS_OK: setBroadcastState(BroadcastState.CONNECTED); mConnecting.set(false); break; case CLIENT_ERROR_UNAUTHORIZED: setBroadcastState(BroadcastState.INVALID_CREDENTIALS); disconnect(); break; case CLIENT_ERROR_FORBIDDEN: setBroadcastState(BroadcastState.CONFIGURATION_ERROR); disconnect(); break; default: setBroadcastState(BroadcastState.ERROR); disconnect(); mLog.debug("Unspecified error: " + response.toString() + " Class:" + object.getClass()); break; } } else { mLog.error("Icecast HTTP broadcaster - unrecognized message [ " + object.getClass() + "] received:" + object.toString()); } } } }