/******************************************************************************* * 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 * ******************************************************************************/ package audio.broadcast.icecast; import alias.Alias; import alias.id.broadcast.BroadcastChannel; import audio.AudioPacket; import audio.broadcast.AudioBroadcaster; import audio.broadcast.BroadcastFactory; import audio.broadcast.BroadcastFormat; import audio.broadcast.BroadcastState; import audio.metadata.AudioMetadata; import audio.metadata.Metadata; import audio.metadata.MetadataType; import controller.ThreadPoolManager; import org.asynchttpclient.AsyncCompletionHandler; import org.asynchttpclient.AsyncHandler; import org.asynchttpclient.BoundRequestBuilder; import org.asynchttpclient.DefaultAsyncHttpClient; import org.asynchttpclient.ListenableFuture; import org.asynchttpclient.Realm; import org.asynchttpclient.Response; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import properties.SystemProperties; import record.wave.AudioPacketMonoWaveReader; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.net.ConnectException; import java.net.Socket; import java.net.SocketAddress; import java.net.SocketException; import java.net.URLEncoder; import java.net.UnknownHostException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.concurrent.atomic.AtomicBoolean; public class IcecastTCPAudioBroadcaster extends AudioBroadcaster { private final static Logger mLog = LoggerFactory.getLogger( IcecastTCPAudioBroadcaster.class ); private final static String UTF8 = "UTF-8"; private final static String TERMINATOR = "\r\n"; private final static String SEPARATOR = ":"; private static final long RECONNECT_INTERVAL_MILLISECONDS = 15000; //15 seconds private long mLastConnectionAttempt = 0; private AtomicBoolean mConnecting = new AtomicBoolean(); private Socket mSocket; private DataOutputStream mOutputStream; private DataInputStream mInputStream; private DefaultAsyncHttpClient mDefaultAsyncHttpClient; private boolean mFirstMetadataUpdateSuppressed = false; private byte[] mSilenceFrame; /** * Creates an Icecast 2.3.2 compatible broadcaster using TCP and a pseudo HTTP 1.0 protocol. This broadcaster is * compatible with Icecast version 2.3.2 and older versions of the server software. * * Note: use @see IcecastHTTPAudioBroadcaster for Icecast version 2.4.x and newer. * * @param configuration */ public IcecastTCPAudioBroadcaster(DefaultAsyncHttpClient httpClient, ThreadPoolManager threadPoolManager, IcecastTCPConfiguration configuration) { super(threadPoolManager, configuration); mDefaultAsyncHttpClient = httpClient; } /** * Configuration information */ private IcecastTCPConfiguration getConfiguration() { return (IcecastTCPConfiguration)getBroadcastConfiguration(); } /** * Broadcast any queued audio packets using Icecast V2 Protocol */ @Override protected void broadcastAudio(byte[] audio) { boolean completed = false; while(!completed) { if(connect()) { try { send(audio); completed = true; } catch(SocketException se) { //The remote server likely disconnected - setup to reconnect mLog.error("Socket Exception on Icecast TCP Audio Broadcaster -- disconnecting and resetting", se); disconnect(false); } catch(IOException e) { completed = true; mLog.error("Error sending audio", e); } } else { completed = true; } } } @Override protected void broadcastMetadata(AudioMetadata metadata) { if(connected() && mFirstMetadataUpdateSuppressed) { StringBuilder sb = new StringBuilder(); if(metadata != null) { Metadata to = metadata.getMetadata(MetadataType.TO); sb.append("TO:"); if(to != null) { if(to.hasAlias()) { sb.append(to.getAlias().getName()); } else { sb.append(to.getValue()); } } else { sb.append("UNKNOWN"); } Metadata from = metadata.getMetadata(MetadataType.FROM); sb.append(" FROM:"); if(from != null) { if(from.hasAlias()) { sb.append(from.getAlias().getName()); } else { sb.append(from.getValue()); } } else { sb.append("UNKNOWN"); } } else { sb.append("Scanning ...."); } try { if(mDefaultAsyncHttpClient == null) { mDefaultAsyncHttpClient = new DefaultAsyncHttpClient(); } String songEncoded = URLEncoder.encode(sb.toString(), UTF8); StringBuilder query = new StringBuilder(); query.append("/admin/metadata?mode=updinfo"); query.append("&mount=").append(getConfiguration().getMountPoint()); query.append("&charset=UTF%2d8"); query.append("&song=").append(songEncoded); Uri uri = new Uri(Uri.HTTP, null, getConfiguration().getHost(), getConfiguration().getPort(), query.toString(), null); BoundRequestBuilder builder = mDefaultAsyncHttpClient.prepareGet(uri.toUrl()); //Use Basic (base64) authentication Realm realm = new Realm.Builder(getConfiguration().getUserName(), getConfiguration().getPassword()) .setScheme(Realm.AuthScheme.BASIC).setUsePreemptiveAuth(true).build(); builder.setRealm(realm); builder.addHeader(IcecastHeader.USER_AGENT.getValue(), SystemProperties.getInstance().getApplicationName()); AsyncHandler handler = new AsyncCompletionHandler() { @Override public Object onCompleted(Response response) throws Exception { return null; } }; mDefaultAsyncHttpClient.executeRequest(builder.build(), handler); } catch(Exception e) { mLog.debug("Error while updating metadata", e); } } else if(connected()) { mFirstMetadataUpdateSuppressed = true; } } /** * Indicates if the audio handler is currently connected to the remote server and capable of streaming audio. * * This method will attempt to establish a connection or reconnect to the streaming server. * @return true if the audio handler can stream audio */ private boolean connect() { if(canConnect()) { createConnection(); } return connected(); } /** * Disconnect from the remote server */ public void disconnect(boolean shutdown) { if(mOutputStream != null) { try { mOutputStream.flush(); mOutputStream.close(); } catch(IOException ioe) { mLog.debug("Error closing output stream", ioe); } mOutputStream = null; } if(mInputStream != null) { try { mInputStream.close(); } catch(IOException ioe) { mLog.debug("Error closing input stream", ioe); } mInputStream = null; } if(mSocket != null) { try { mSocket.close(); } catch(IOException ioe) { mLog.error("Error closing socket", ioe); } mSocket = null; } if(shutdown) { setBroadcastState(BroadcastState.DISCONNECTED); } else { setBroadcastState(BroadcastState.READY); } } /** * Creates a connnection to the remote server using the shoutcast configuration information. Once disconnected * following a successful connection, attempts to reestablish a connection on a set interval */ private void createConnection() { if(mConnecting.compareAndSet(false, true)) { if(canConnect() && System.currentTimeMillis() - mLastConnectionAttempt >= RECONNECT_INTERVAL_MILLISECONDS) { mLog.debug("Creating a connection to icecast server"); mLastConnectionAttempt = System.currentTimeMillis(); createSocket(); if(mSocket.isConnected()) { StringBuilder sb = new StringBuilder(); sb.append("SOURCE ").append(getConfiguration().getMountPoint()); sb.append(" HTTP/1.0").append(TERMINATOR); sb.append("Authorization: ").append(getConfiguration().getEncodedCredentials()).append(TERMINATOR); sb.append(IcecastHeader.USER_AGENT.getValue()).append(SEPARATOR) .append(SystemProperties.getInstance().getApplicationName()).append(TERMINATOR); sb.append(IcecastHeader.CONTENT_TYPE.getValue()).append(SEPARATOR) .append(getConfiguration().getBroadcastFormat().getValue()).append(TERMINATOR); sb.append(IcecastHeader.PUBLIC.getValue()).append(SEPARATOR) .append(getConfiguration().isPublic() ? "1" : "0").append(TERMINATOR); if(getConfiguration().hasName()) { sb.append(IcecastHeader.NAME.getValue()).append(SEPARATOR) .append(getConfiguration().getName()).append(TERMINATOR); } if(getConfiguration().hasGenre()) { sb.append(IcecastHeader.GENRE.getValue()).append(SEPARATOR) .append(getConfiguration().getGenre()).append(TERMINATOR); } if(getConfiguration().hasDescription()) { sb.append(IcecastHeader.DESCRIPTION.getValue()).append(SEPARATOR) .append(getConfiguration().getDescription()).append(TERMINATOR); } sb.append(getAudioInfoMetadata()); sb.append(TERMINATOR).append(TERMINATOR); try { mLog.debug("Sending connection string"); send(sb.toString()); } catch(IOException e) { mLog.error("Error while connecting with ...\n" + sb.toString(), e); } } mLog.debug("Checking response ..."); checkResponse(); } mConnecting.set(false); } } private String getAudioInfoMetadata() { StringBuilder sb = new StringBuilder(); if(getConfiguration().hasBitRate() || getConfiguration().hasChannels() || getConfiguration().hasSampleRate()) { sb.append(IcecastHeader.AUDIO_INFO.getValue()).append(SEPARATOR); boolean contentAdded = false; if(getConfiguration().hasBitRate()) { sb.append("bitrate=").append(getConfiguration().getBitRate()); contentAdded = true; } if(getConfiguration().hasChannels()) { if(contentAdded) { sb.append(";"); } sb.append("channels=").append(getConfiguration().getChannels()); contentAdded = true; } if(getConfiguration().hasSampleRate()) { if(contentAdded) { sb.append(";"); } sb.append("samplerate=").append(getConfiguration().getSampleRate()); } } return sb.toString(); } /** * Creates a socket connection to the remote server and sets the state to CONNECTING. */ private void createSocket() { if(mSocket == null) { mLog.debug("Creating socket"); try { mSocket = new Socket(getConfiguration().getHost(), getConfiguration().getPort()); mOutputStream = new DataOutputStream(mSocket.getOutputStream()); mInputStream = new DataInputStream(mSocket.getInputStream()); } catch(UnknownHostException uhe) { setBroadcastState(BroadcastState.UNKNOWN_HOST); mLog.error("Unknown host or port. Unable to create connection to streaming server host[" + getConfiguration().getHost() + "] and port[" + getConfiguration().getPort() + "] - will reattempt connection periodically"); return; } catch(ConnectException ce) { setBroadcastState(BroadcastState.NO_SERVER); mLog.error("Connection refused. Unable to create connection to streaming server host[" + getConfiguration().getHost() + "] and port[" + getConfiguration().getPort() + "]"); return; } catch(IOException ioe) { setBroadcastState(BroadcastState.ERROR); mLog.error("Error connecting to streaming server host[" + getConfiguration().getHost() + "] and port[" + getConfiguration().getPort() + "]", ioe); return; } } if(mSocket.isConnected()) { setBroadcastState(BroadcastState.CONNECTING); } else { try { SocketAddress address = getConfiguration().getAddress(); mSocket.connect(address); setBroadcastState(BroadcastState.CONNECTING); } catch(UnknownHostException uhe) { setBroadcastState(BroadcastState.UNKNOWN_HOST); } catch(IOException e) { setBroadcastState(BroadcastState.ERROR); mLog.error("Error connecting to streaming server host[" + getConfiguration().getHost() + "] and port[" + getConfiguration().getPort() + "]", e); } } } /** * Sends the string data to the remote server * @param data to send * @throws IOException if there is an error communicating with the remote server */ private void send(String data) throws IOException { if(data != null && !data.isEmpty() && mOutputStream != null) { mOutputStream.writeBytes(data); } } /** * Sends the byte data to the remote server * @param data to send * @throws IOException if there is an error communicating with the remote server */ private void send(byte[] data) throws IOException { if(connected()) { int sent = 0; while(sent < data.length) { int available = data.length - sent; mOutputStream.write(data, sent, available); sent += available; } } } private String getResponse() throws IOException { if(mInputStream != null) { int bytesAvailable = mInputStream.available(); if(bytesAvailable > 0) { byte[] responseBuffer = new byte[bytesAvailable]; int bytesRead = 0; while(bytesRead < bytesAvailable) { bytesRead += mInputStream.read(responseBuffer, bytesRead, bytesAvailable - bytesRead); } return new String(responseBuffer); } } return null; } private void checkResponse() { String response = null; try { Thread.sleep(250); response = getResponse(); } catch(InterruptedException ie) { mLog.debug("Interrupted ...", ie); } catch(IOException e) { mLog.error("Error while retrieving server response message", e); setBroadcastState(BroadcastState.ERROR); } if(response != null && !response.isEmpty()) { if(response.startsWith("HTTP/1.0 200 OK")) { setBroadcastState(BroadcastState.CONNECTED); } else if(response.contains("Invalid Password") || response.contains("Authentication Required")) { setBroadcastState(BroadcastState.INVALID_PASSWORD); } else { mLog.debug("Unrecognized server response:" + response); setBroadcastState(BroadcastState.ERROR); } } else { setBroadcastState(BroadcastState.CONNECTED); mLog.debug("Response was empty"); } } public static void main(String[] args) { boolean test = false; IcecastTCPConfiguration config = new IcecastTCPConfiguration(BroadcastFormat.MP3); String streamName = "Broadcastify SDRTrunk #2"; config.setName(streamName); config.setHost("audio3.broadcastify.com"); config.setPort(80); config.setMountPoint("/k0yrdpx7zn4h"); config.setUserName("source"); config.setPassword("k8j9405n"); config.setDescription("SDRTrunk Test Feed #2"); config.setGenre("Scanner"); config.setPublic(false); config.setURL("http://www.radioreference.com"); config.setBitRate(16); AudioMetadata metadata = new AudioMetadata(0, false); Alias toAlias = new Alias("ToAlias"); toAlias.addAliasID(new BroadcastChannel(streamName)); metadata.receive(new Metadata(MetadataType.TO, "1234", toAlias )); if(test) { mLog.debug("Auth:" + config.getEncodedCredentials()); } else { ThreadPoolManager threadPoolManager = new ThreadPoolManager(); DefaultAsyncHttpClient httpClient = new DefaultAsyncHttpClient(); final AudioBroadcaster audioBroadcaster = BroadcastFactory.getBroadcaster(httpClient, threadPoolManager,config); Path path = Paths.get("/home/denny/Music/PCM.wav"); mLog.debug("Opening: " + path.toString()); mLog.debug("Registering and starting audio playback"); int playCount = 0; while(true) { mLog.debug("Playback started [" + path.toString() + "]"); toAlias.setName("Playback #" + playCount++); try (AudioPacketMonoWaveReader reader = new AudioPacketMonoWaveReader(path, true)) { reader.setMetadata(metadata); reader.setListener(audioBroadcaster); reader.read(); audioBroadcaster.receive(new AudioPacket(AudioPacket.Type.END, metadata)); } catch (IOException e) { mLog.error("Error", e); } mLog.debug("Playback ended [" + path.toString() + "]"); } } } }