/******************************************************************************* * sdrtrunk * Copyright (C) 2014-2017 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.shoutcast.v2; import audio.broadcast.AudioBroadcaster; import audio.broadcast.BroadcastState; import audio.broadcast.IBroadcastMetadataUpdater; import audio.broadcast.shoutcast.v2.ultravox.AuthenticateBroadcast; import audio.broadcast.shoutcast.v2.ultravox.CacheableXMLMetadata; import audio.broadcast.shoutcast.v2.ultravox.ConfigureIcyName; import audio.broadcast.shoutcast.v2.ultravox.ConfigureIcyPublic; import audio.broadcast.shoutcast.v2.ultravox.MP3Audio; import audio.broadcast.shoutcast.v2.ultravox.NegotiateMaxPayloadSize; import audio.broadcast.shoutcast.v2.ultravox.RequestCipher; import audio.broadcast.shoutcast.v2.ultravox.SetupBroadcast; import audio.broadcast.shoutcast.v2.ultravox.Standby; import audio.broadcast.shoutcast.v2.ultravox.StreamMimeType; import audio.broadcast.shoutcast.v2.ultravox.TerminateBroadcast; import audio.broadcast.shoutcast.v2.ultravox.UltravoxMessage; import audio.broadcast.shoutcast.v2.ultravox.UltravoxMessageFactory; import audio.broadcast.shoutcast.v2.ultravox.UltravoxMessageType; import audio.broadcast.shoutcast.v2.ultravox.UltravoxMetadata; import audio.broadcast.shoutcast.v2.ultravox.UltravoxProtocolFactory; import channel.metadata.Metadata; import org.apache.mina.core.RuntimeIoException; 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.ProtocolCodecFilter; import org.apache.mina.transport.socket.nio.NioSocketConnector; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import properties.SystemProperties; import util.ThreadPool; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.ConnectException; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; import java.util.concurrent.LinkedTransferQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public class ShoutcastV2AudioBroadcaster extends AudioBroadcaster implements IBroadcastMetadataUpdater { private final static Logger mLog = LoggerFactory.getLogger(ShoutcastV2AudioBroadcaster.class); private static final long RECONNECT_INTERVAL_MILLISECONDS = 30000; //30 seconds private int mMaxPayloadSize = 16377; private NioSocketConnector mSocketConnector; private IoSession mStreamingSession = null; private long mLastConnectionAttempt = 0; private AtomicBoolean mConnecting = new AtomicBoolean(); private LinkedTransferQueue<UltravoxMessage> mMetadataMessageQueue = new LinkedTransferQueue<>(); /** * Shoutcast 2.x (Ultravox 2.1) broadcaster. This broadcaster is compatible with Shoutcast 2.x and newer versions * of the server software. * * Note: use @see ShoutcastV1AudioBroadcaster for Shoutcast version 1.x and older. * * This broadcaster uses the Apache Mina library for the streaming socket connection. The * ShoutcastV2IOHandler manages all interaction with the Shoutcast server and manages the overall broadcast state. * * @param configuration for the Shoutcast V2 stream */ public ShoutcastV2AudioBroadcaster(ShoutcastV2Configuration configuration) { super(configuration); } public ShoutcastV2Configuration getConfiguration() { return (ShoutcastV2Configuration) getBroadcastConfiguration(); } /** * Broadcasts the audio frame or sequence */ @Override protected void broadcastAudio(byte[] audio) { //Dispatch any queued metadata messages UltravoxMessage metadataMessage = mMetadataMessageQueue.poll(); while(metadataMessage != null) { mStreamingSession.write(metadataMessage); metadataMessage = mMetadataMessageQueue.poll(); } //Dispatch audio message if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) { MP3Audio mp3Audio = new MP3Audio(); mp3Audio.setPayload(audio); mStreamingSession.write(mp3Audio); } } @Override public void update(Metadata metadata) { List<UltravoxMessage> metadataMessages = getMetadataMessages(metadata); if(!metadataMessages.isEmpty()) { mMetadataMessageQueue.addAll(metadataMessages); } } @Override protected IBroadcastMetadataUpdater getMetadataUpdater() { return this; } /** * (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(ShoutcastV2AudioBroadcaster.class)); mSocketConnector.getFilterChain().addLast("codec", new ProtocolCodecFilter(new UltravoxProtocolFactory())); mSocketConnector.setHandler(new ShoutcastV2IOHandler()); } 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.error("Failed to connect", rie); } else { setBroadcastState(BroadcastState.ERROR); mLog.error("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(connected()) { if(mStreamingSession != null) { mStreamingSession.write(new TerminateBroadcast()); mStreamingSession.closeNow(); } } } /** * Encodes the list of metadata in one or more Ultravox CacheableXMLMetadata messages according to the maximum negotiated * payload size for the current connection. Each entry in the list of metadata strings should be an xml encoded * value: <tag>value</tag> * * See UltravoxMetadata.TAG.asXML(String value) * * @param metadata containing audio metadata tags and attributes * @return a sequence of CacheableXMLMetadata messages sufficient to carry the complete set of metadata */ private List<UltravoxMessage> getMetadataMessages(Metadata metadata) { StringBuilder sb = new StringBuilder(); sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\" ?><metadata>"); try { sb.append(UltravoxMetadata.ALBUM_TITLE.asXML(getConfiguration().getName())); sb.append(UltravoxMetadata.BROADCAST_CLIENT_APPLICATION.asXML(SystemProperties.getInstance().getApplicationName())); if(getConfiguration().hasGenre()) { sb.append(UltravoxMetadata.GENRE.asXML(getConfiguration().getGenre())); } if(getConfiguration().hasURL()) { sb.append(UltravoxMetadata.URL.asXML(getConfiguration().getURL())); } if(metadata != null) { if(metadata.getPrimaryAddressTo().hasAlias()) { sb.append(UltravoxMetadata.TITLE_2.asXML(metadata.getPrimaryAddressTo().getAlias().getName())); } else if(metadata.getPrimaryAddressTo().hasIdentifier()) { sb.append(UltravoxMetadata.TITLE_2.asXML(metadata.getPrimaryAddressTo().getIdentifier())); } if(metadata.getPrimaryAddressFrom().hasAlias()) { sb.append(UltravoxMetadata.TITLE_3.asXML("From:" + metadata.getPrimaryAddressFrom().getAlias().getName())); } else if(metadata.getPrimaryAddressFrom().hasIdentifier()) { sb.append(UltravoxMetadata.TITLE_3.asXML("From:" + metadata.getPrimaryAddressFrom().getIdentifier())); } } else { sb.append(UltravoxMetadata.TITLE_2.asXML("Scanning ...")); } } catch(UnsupportedEncodingException uee) { mLog.error("UTF-8 Encoding is not supported - shoutcast/ultravox metadata will not be updated"); } sb.append("</metadata>"); byte[] xml = sb.toString().getBytes(); int pointer = 0; int messageCounter = 1; int messageCount = (int) Math.ceil((double) xml.length / (double) (mMaxPayloadSize - 6)); if(messageCount > 32) { messageCount = 32; //Max number of metadata messages in a sequence } List<UltravoxMessage> messages = new ArrayList<>(); while(pointer < xml.length && messageCounter <= messageCount) { int payloadSize = Math.min(mMaxPayloadSize - 6, xml.length - pointer); byte[] payload = new byte[payloadSize + 6]; payload[1] = (byte) (0x01); payload[3] = (byte) (messageCount & 0xFF); payload[5] = (byte) (messageCounter & 0xFF); System.arraycopy(xml, pointer, payload, 6, payloadSize); CacheableXMLMetadata message = new CacheableXMLMetadata(); message.setPayload(payload); messages.add(message); pointer += payloadSize; } return messages; } /** * IO Handler for managing Shoutcast V2 connection and credentials */ public class ShoutcastV2IOHandler extends IoHandlerAdapter { /** * Sends stream configuration and user credentials upon connecting to remote server */ @Override public void sessionOpened(IoSession session) throws Exception { session.write(UltravoxMessageFactory.getMessage(UltravoxMessageType.REQUEST_CIPHER)); } @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 cause) throws Exception { if(cause instanceof IOException) { IOException ioe = (IOException)cause; if(ioe.getMessage() != null) { String reason = ioe.getMessage(); if(reason.startsWith("Connection reset")) { mLog.info("Streaming connection reset by remote server - reestablishing connection"); disconnect(); connect(); } else if(reason.startsWith("Operation timed out")) { mLog.info("Streaming connection timed out - resetting connection"); disconnect(); connect(); } else { setBroadcastState(BroadcastState.ERROR); disconnect(); mLog.error("Unrecognized IO error: " + reason + ". Streaming halted."); } } else { setBroadcastState(BroadcastState.ERROR); disconnect(); mLog.error("Unspecified IO error - streaming halted."); } } else { mLog.error("Broadcast error", cause); setBroadcastState(BroadcastState.ERROR); disconnect(); } mConnecting.set(false); } @Override public void messageReceived(IoSession session, Object object) throws Exception { if(object instanceof UltravoxMessage) { UltravoxMessage ultravoxMessage = (UltravoxMessage) object; switch(ultravoxMessage.getMessageType()) { case REQUEST_CIPHER: String cipherKey = ((RequestCipher) ultravoxMessage).getCipher(); AuthenticateBroadcast authenticateBroadcast = new AuthenticateBroadcast(); authenticateBroadcast.setCredentials(cipherKey, getConfiguration().getStreamID(), getConfiguration().getUserID(), getConfiguration().getPassword()); session.write(authenticateBroadcast); break; case AUTHENTICATE_BROADCAST: if(ultravoxMessage.isErrorResponse()) { String errorMessage = ultravoxMessage.getErrorMessage(); if(errorMessage.startsWith(AuthenticateBroadcast.STREAM_ID_ERROR)) { setBroadcastState(BroadcastState.INVALID_MOUNT_POINT); } else { mLog.error("Invalid Credentials - response: " + ultravoxMessage.getPayload()); setBroadcastState(BroadcastState.INVALID_CREDENTIALS); } } else { StreamMimeType streamMimeType = new StreamMimeType(); streamMimeType.setFormat(getConfiguration().getBroadcastFormat()); session.write(streamMimeType); } break; case STREAM_MIME_TYPE: if(ultravoxMessage.isErrorResponse()) { mLog.error("Unsupported Audio Format:" + getConfiguration().getBroadcastFormat().toString() + " - " + ultravoxMessage.getErrorMessage()); setBroadcastState(BroadcastState.UNSUPPORTED_AUDIO_FORMAT); } else { SetupBroadcast setupBroadcast = new SetupBroadcast(); //Use the same value for average and minimum bit rates setupBroadcast.setBitRate(getConfiguration().getBitRate(), getConfiguration().getBitRate()); session.write(setupBroadcast); } break; case SETUP_BROADCAST: if(ultravoxMessage.isErrorResponse()) { mLog.error("Unsupported Audio Bit Rate:" + getConfiguration().getBitRate() + " - " + ultravoxMessage.getErrorMessage()); setBroadcastState(BroadcastState.UNSUPPORTED_AUDIO_FORMAT); } else { NegotiateMaxPayloadSize negotiateMaxPayloadSize = new NegotiateMaxPayloadSize(); negotiateMaxPayloadSize.setMaximumPayloadSize(16377, 4192); session.write(negotiateMaxPayloadSize); } break; case NEGOTIATE_MAX_PAYLOAD_SIZE: if(ultravoxMessage.isErrorResponse()) { mLog.error("Unsupported maximum payload size (18 min - 36 kbps max) - " + ultravoxMessage.getErrorMessage()); setBroadcastState(BroadcastState.UNSUPPORTED_AUDIO_FORMAT); } else { mMaxPayloadSize = ((NegotiateMaxPayloadSize) ultravoxMessage).getMaximumPayloadSize(); ConfigureIcyPublic configureIcyPublic = new ConfigureIcyPublic(); configureIcyPublic.setPublic(getConfiguration().isPublic()); session.write(configureIcyPublic); } break; case CONFIGURE_ICY_PUBLIC: if(ultravoxMessage.isErrorResponse()) { mLog.error("Error setting shoutcast stream as public - " + ultravoxMessage.getErrorMessage()); setBroadcastState(BroadcastState.ERROR); } else { ConfigureIcyName configureIcyName = new ConfigureIcyName(); configureIcyName.setName(getConfiguration().getName()); session.write(configureIcyName); } break; case CONFIGURE_ICY_NAME: if(ultravoxMessage.isErrorResponse()) { mLog.error("Error setting shoutcast stream name - " + ultravoxMessage.getErrorMessage()); setBroadcastState(BroadcastState.CONFIGURATION_ERROR); } else { session.write(new Standby()); } break; case STANDBY: if(ultravoxMessage.isErrorResponse()) { mLog.error("Error following stream configuration and standby - " + ultravoxMessage.getErrorMessage()); setBroadcastState(BroadcastState.ERROR); } else { setBroadcastState(BroadcastState.CONNECTED); } break; default: mLog.error("Unrecognized ultravox message:" + ultravoxMessage.getMessageType() + " payload:" + ultravoxMessage.getPayload()); setBroadcastState(BroadcastState.ERROR); } } else { mLog.debug("Unrecognized message received from shoutcast v2 server:" + object.toString()); setBroadcastState(BroadcastState.ERROR); } } } }