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
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
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
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
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
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
190 socket = SocketFactory.getDefault().createSocket(url.getHost(),
191 url.getPort() < 0 ? 80 : url.getPort());
192
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
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
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 }