View Javadoc

1   /***
2    *     Ambient - A music player for the Android platform
3    Copyright (C) 2007 Martin Vysny
4    
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU General Public License as published by
7    the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9    
10   This program is distributed in the hope that it will be useful,
11   but WITHOUT ANY WARRANTY; without even the implied warranty of
12   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   GNU General Public License for more details.
14  
15   You should have received a copy of the GNU General Public License
16   along with this program.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  
19  package sk.baka.ambient.stream.shoutcast;
20  
21  import java.io.BufferedInputStream;
22  import java.io.IOException;
23  import java.io.InputStream;
24  import java.io.OutputStream;
25  import java.net.Socket;
26  import java.net.URL;
27  import java.util.HashMap;
28  import java.util.Map;
29  
30  import javax.net.SocketFactory;
31  
32  import sk.baka.ambient.commons.IOUtils;
33  
34  /***
35   * <p>
36   * Polls a SHOUTcast server on given URL address. The stream itself will contain
37   * mp3 data only - all shoutcast-related metadata are reported as events and
38   * filtered out.
39   * </p>
40   * <p>
41   * Throws {@link RadioStreamCorruptedException} when the stream corruption is
42   * detected.
43   * </p>
44   * 
45   * @author Martin Vysny
46   */
47  public class ShoutcastInputStream extends InputStream {
48  
49  	@Override
50  	public int read(byte[] b, int offset, int length) throws IOException {
51  		// make sure that the metaint was read correctly
52  		getStreamedData();
53  		if (metaint < 0) {
54  			return getStreamedData().read(b, offset, length);
55  		}
56  		if (bytesToMetadata > 0) {
57  			int toRead = Math.min(bytesToMetadata, length);
58  			int result = getStreamedData().read(b, offset, toRead);
59  			if (result >= 0) {
60  				bytesToMetadata -= result;
61  			}
62  			return result;
63  		}
64  		readInstreamMetadata();
65  		return read(b, offset, length);
66  	}
67  
68  	private void readInstreamMetadata() throws IOException {
69  		bytesToMetadata = metaint;
70  		int chunkLength = getStreamedData().read();
71  		if (chunkLength <= 0)
72  			return;
73  		chunkLength *= 16;
74  		// read the metadata
75  		final byte[] metadataBuffer = new byte[chunkLength];
76  		int bufPointer = 0;
77  		while (bufPointer < chunkLength) {
78  			final int result = getStreamedData().read(metadataBuffer,
79  					bufPointer, chunkLength - bufPointer);
80  			if (result < 0)
81  				return;
82  			bufPointer += result;
83  		}
84  		while ((chunkLength > 0) && (metadataBuffer[chunkLength - 1] == 0)) {
85  			chunkLength--;
86  		}
87  		String metadata = new String(metadataBuffer, 0, chunkLength, "UTF-8");
88  		// parse the metadata
89  		final Map<String, String> meta = new HashMap<String, String>();
90  		while (metadata.length() > 0) {
91  			int i = metadata.indexOf("='");
92  			if (i < 0)
93  				break;
94  			final String mdName = metadata.substring(0, i);
95  			metadata = metadata.substring(i + 2);
96  			i = metadata.indexOf("';");
97  			if (i < 0)
98  				break;
99  			final String mdValue = metadata.substring(0, i);
100 			metadata = metadata.substring(i + 2);
101 			meta.put(mdName, mdValue);
102 		}
103 		if (meta.isEmpty()) {
104 			throw new RadioStreamCorruptedException("No tags in non-empty meta");
105 		}
106 		// invoke listener
107 		final String title = meta.get("StreamTitle");
108 		listener.metadataReceived(title, meta);
109 	}
110 
111 	@Override
112 	public int read() throws IOException {
113 		if (metaint < 0) {
114 			return getStreamedData().read();
115 		}
116 		if (bytesToMetadata > 0) {
117 			int result = getStreamedData().read();
118 			if (result >= 0) {
119 				bytesToMetadata--;
120 			}
121 			return result;
122 		}
123 		readInstreamMetadata();
124 		return read();
125 	}
126 
127 	@Override
128 	public int read(byte[] b) throws IOException {
129 		return read(b, 0, b.length);
130 	}
131 
132 	private final IShoutcastListener listener;
133 
134 	/***
135 	 * Creates the object. The SHOUTcast server is polled on first invocation of
136 	 * the <code>read</code> method.
137 	 * 
138 	 * @param url
139 	 *            the server URL, must be a http (or file - for debugging
140 	 *            purposes) protocol.
141 	 * @param listener
142 	 *            the listener.
143 	 */
144 	public ShoutcastInputStream(final URL url, final IShoutcastListener listener) {
145 		super();
146 		isFile = "file".equals(url.getProtocol());
147 		final boolean isHttp = "http".equals(url.getProtocol());
148 		if (!isHttp && !isFile) {
149 			throw new IllegalArgumentException(url + " is not a http/file URL");
150 		}
151 		this.url = url;
152 		this.listener = listener;
153 	}
154 
155 	/***
156 	 * Creates the object.
157 	 * 
158 	 * @param stream
159 	 *            the SHOUTcast stream.
160 	 * @param listener
161 	 *            the listener.
162 	 * @throws IOException
163 	 */
164 	ShoutcastInputStream(final InputStream stream,
165 			final IShoutcastListener listener) throws IOException {
166 		super();
167 		isFile = false;
168 		this.url = null;
169 		this.listener = listener;
170 		this.streamedData = stream;
171 		readOpeningMetadata();
172 	}
173 
174 	private InputStream getServerStream() throws IOException {
175 		// build a request
176 		final StringBuilder b = new StringBuilder();
177 		b.append("GET ");
178 		if (url.getPath().length() == 0) {
179 			b.append('/');
180 		} else {
181 			b.append(url.getPath());
182 		}
183 		b.append(" HTTP/1.1\r\n");
184 		b.append("User-Agent: Ambient\r\n");
185 		b.append("icy-metadata:1\r\n");
186 		b.append("\r\n");
187 		final String request = b.toString();
188 		final byte[] reqByte = request.getBytes("UTF-8");
189 		// open communication socket
190 		socket = SocketFactory.getDefault().createSocket(url.getHost(),
191 				url.getPort() < 0 ? 80 : url.getPort());
192 		// feed the request
193 		final OutputStream out = socket.getOutputStream();
194 		out.write(reqByte);
195 		return new BufferedInputStream(socket.getInputStream());
196 	}
197 
198 	private final boolean isFile;
199 
200 	/***
201 	 * The SHOUTcast server URL.
202 	 */
203 	public final URL url;
204 
205 	/***
206 	 * The underlying streamed data.
207 	 */
208 	private InputStream streamedData = null;
209 
210 	private InputStream getStreamedData() throws IOException {
211 		if (streamedData == null) {
212 			if (isFile) {
213 				streamedData = url.openStream();
214 			} else {
215 				streamedData = getServerStream();
216 			}
217 			readOpeningMetadata();
218 		}
219 		return streamedData;
220 	}
221 
222 	private String readLine() throws IOException {
223 		return IOUtils.readLine(getStreamedData());
224 	}
225 
226 	private void readOpeningMetadata() throws IOException {
227 		// get the metadata
228 		final String icyResponse = readLine();
229 		if (icyResponse == null)
230 			throw new IOException("Socked is closed");
231 		if (!icyResponse.startsWith("ICY "))
232 			throw new IOException("Not a SHOUTcast radio stream");
233 		if (!icyResponse.equals("ICY 200 OK"))
234 			throw new IOException("SHOUTcast error: " + icyResponse);
235 		// seems ok. read the header and search for the mp3 data.
236 		final Map<String, String> metadata = new HashMap<String, String>();
237 		for (String metadataRow = readLine(); metadataRow.length() != 0; metadataRow = readLine()) {
238 			int i = metadataRow.indexOf(':');
239 			final String mdName = metadataRow.substring(0, i);
240 			final String mdValue = metadataRow.substring(i + 1, metadataRow
241 					.length());
242 			metadata.put(mdName, mdValue);
243 		}
244 		final String name = metadata.get("icy-name");
245 		final String genre = metadata.get("icy-genre");
246 		String metaint = metadata.get("icy-metaint");
247 		listener.opened(name, genre, metaint != null, metadata);
248 		if (metaint != null) {
249 			metaint = metaint.trim();
250 			this.metaint = Integer.parseInt(metaint);
251 			if (this.metaint < 1) {
252 				this.metaint = -1;
253 			}
254 			bytesToMetadata = this.metaint;
255 		}
256 	}
257 
258 	/***
259 	 * If greater than zero, a metadata chunk is repeated each
260 	 * <code>metaint</code> bytes.
261 	 */
262 	private int metaint = -1;
263 
264 	/***
265 	 * 
266 	 */
267 	private int bytesToMetadata = -1;
268 
269 	/***
270 	 * If not <code>null</code> then this socket is used to get stream from the
271 	 * server.
272 	 */
273 	private Socket socket = null;
274 
275 	@Override
276 	public void close() throws IOException {
277 		if (socket != null) {
278 			socket.close();
279 		} else {
280 			if (streamedData != null) {
281 				streamedData.close();
282 			}
283 		}
284 	}
285 }