mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-13 00:57:31 -05:00
526 lines
20 KiB
Java
526 lines
20 KiB
Java
/*******************************************************************************
|
|
* 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.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();
|
|
}
|
|
}
|
|
};
|
|
|
|
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>");
|
|
|
|
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 ..."));
|
|
}
|
|
|
|
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) cause).getMessage().startsWith("Connection reset"))
|
|
{
|
|
IOException ioe = (IOException) cause;
|
|
|
|
if(ioe.getMessage() != null && ioe.getMessage().startsWith("Connection reset"))
|
|
{
|
|
mLog.info("Streaming connection reset by remote server - reestablishing connection");
|
|
disconnect();
|
|
connect();
|
|
}
|
|
else
|
|
{
|
|
mLog.error("IO Exception", ioe);
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|