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.playlist;
20  
21  import java.io.File;
22  import java.io.FileInputStream;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.io.InputStreamReader;
26  import java.io.LineNumberReader;
27  import java.text.ParseException;
28  import java.util.ArrayList;
29  import java.util.HashMap;
30  import java.util.List;
31  import java.util.Map;
32  import java.util.StringTokenizer;
33  
34  import org.xml.sax.Attributes;
35  import org.xml.sax.SAXException;
36  import org.xml.sax.helpers.DefaultHandler;
37  
38  import sk.baka.ambient.collection.TrackMetadataBean;
39  import sk.baka.ambient.collection.TrackOriginEnum;
40  import sk.baka.ambient.commons.IOUtils;
41  import sk.baka.ambient.commons.MiscUtils;
42  
43  /***
44   * Contains parsers for PLS, m3u, m3u8 and WPL playlists.
45   * 
46   * @author Martin Vysny
47   */
48  public final class Parsers {
49  
50  	private Parsers() {
51  		throw new Error();
52  	}
53  
54  	/***
55  	 * Parses the PLS playlist and returns list of tracks contained in the
56  	 * playlist.
57  	 * 
58  	 * @param in
59  	 *            the playlist stream. the stream is always closed.
60  	 * @param directory
61  	 *            resolve all relative filenames against this directory. May be
62  	 *            <code>null</code> if no resolving will be performed.
63  	 * @return list of parsed tracks.
64  	 * @throws IOException
65  	 *             if i/o error occurs.
66  	 * @throws ParseException
67  	 *             on parse error
68  	 */
69  	public static List<TrackMetadataBean> parsePls(final InputStream in,
70  			final String directory) throws IOException, ParseException {
71  		try {
72  			final String parent = directory == null ? null : IOUtils
73  					.removeTrailingSlash(directory);
74  			final List<String> buffer = new ArrayList<String>(3);
75  			// read header
76  			final LineNumberReader reader = new LineNumberReader(
77  					new InputStreamReader(in, "UTF-8"));
78  			readLines(reader, 1, buffer);
79  			if (!"[playlist]".equals(buffer.get(0))) {
80  				throw new ParseException("Missing the [playlist] header", 1);
81  			}
82  			readLines(reader, 1, buffer);
83  			final String sEntryNumber = getValue(reader.getLineNumber(), buffer
84  					.get(0), "NumberOfEntries");
85  			final int entryNumber = parseInt(reader.getLineNumber(),
86  					sEntryNumber, "NumberOfEntries") + 1;
87  			// read tracks
88  			final List<TrackMetadataBean> result = new ArrayList<TrackMetadataBean>();
89  			for (int i = 1; i < entryNumber; i++) {
90  				readLines(reader, 3, buffer);
91  				String url = getValue(reader.getLineNumber(), buffer.get(0),
92  						"File" + i);
93  				final TrackOriginEnum origin = TrackOriginEnum
94  						.fromLocation(url);
95  				url = resolve(origin, parent, url);
96  				final String title = getValue(reader.getLineNumber(), buffer
97  						.get(1), "Title" + i);
98  				final String sLength = getValue(reader.getLineNumber(), buffer
99  						.get(2), "Length" + i);
100 				final int length = parseInt(reader.getLineNumber(), sLength,
101 						"Length" + i);
102 				final TrackMetadataBean.Builder b = TrackMetadataBean
103 						.newBuilder();
104 				b.setLocation(url).setOrigin(origin).setTitle(title).setLength(
105 						length < 0 ? 0 : length);
106 				final TrackMetadataBean track = b.build(-1);
107 				result.add(track);
108 			}
109 			return result;
110 		} finally {
111 			MiscUtils.closeQuietly(in);
112 		}
113 	}
114 
115 	private static String resolve(final TrackOriginEnum origin,
116 			final String parent, final String url) {
117 		if ((origin == TrackOriginEnum.LocalFs) && (parent != null)
118 				&& !new File(url).isAbsolute()) {
119 			return parent + "/" + url;
120 		}
121 		return url;
122 	}
123 
124 	private static int parseInt(final int lineNumber, final String num,
125 			final String propName) throws ParseException {
126 		try {
127 			return Integer.parseInt(num);
128 		} catch (NumberFormatException ex) {
129 			throw new ParseException("Failed to parse " + propName + " value "
130 					+ num, lineNumber);
131 		}
132 	}
133 
134 	/***
135 	 * Reads exactly <code>lineCount</code> lines from given reader. Skips
136 	 * {@link MiscUtils#isEmptyOrWhitespace(String)} lines.
137 	 * 
138 	 * @param reader
139 	 *            the reader to poll
140 	 * @param lineCount
141 	 *            read this count of lines
142 	 * @param target
143 	 *            put the lines here.
144 	 * @throws ParseException
145 	 *             if there is not enough lines left in the stream.
146 	 * @throws IOException
147 	 *             if i/o error occurs.
148 	 */
149 	private static void readLines(final LineNumberReader reader,
150 			final int lineCount, final List<String> target)
151 			throws ParseException, IOException {
152 		target.clear();
153 		int linesToRead = lineCount;
154 		while (linesToRead > 0) {
155 			final String line = reader.readLine();
156 			if (line == null) {
157 				throw new ParseException("Expected " + lineCount + " lines; "
158 						+ linesToRead + " missing", reader.getLineNumber());
159 			}
160 			if (!MiscUtils.isEmptyOrWhitespace(line)) {
161 				target.add(line.trim());
162 				linesToRead--;
163 			}
164 		}
165 	}
166 
167 	private static String getValue(final int lineNumber, final String line,
168 			final String propertyName) throws ParseException {
169 		if (!line.toLowerCase().startsWith(propertyName.toLowerCase() + "=")) {
170 			throw new ParseException("Expected property '" + propertyName
171 					+ "' but got " + line, lineNumber);
172 		}
173 		final String result = line.substring(propertyName.length() + 1);
174 		return result.trim();
175 	}
176 
177 	/***
178 	 * Parses the M3U playlist and returns list of tracks contained in the
179 	 * playlist.
180 	 * 
181 	 * @param in
182 	 *            the playlist stream. the stream is always closed.
183 	 * @param directory
184 	 *            resolve all relative filenames against this directory. May be
185 	 *            <code>null</code> if no resolving will be performed.
186 	 * @param unicode
187 	 *            if <code>true</code> then the M3U8 UTF-8 encoding is used.
188 	 *            If <code>false</code> then the M3U ISO8859_1 encoding is
189 	 *            used.
190 	 * @return list of tracks
191 	 * @throws IOException
192 	 *             if i/o error occurs.
193 	 */
194 	public static List<TrackMetadataBean> parseM3u(final InputStream in,
195 			final String directory, final boolean unicode) throws IOException {
196 		try {
197 			final String parent = directory == null ? null : IOUtils
198 					.removeTrailingSlash(directory);
199 			final List<TrackMetadataBean> result = new ArrayList<TrackMetadataBean>();
200 			final LineNumberReader reader = new LineNumberReader(
201 					new InputStreamReader(in, unicode ? "UTF-8" : "ISO8859_1"));
202 			String nextTitle = null;
203 			Integer nextDuration = null;
204 			for (String line = reader.readLine(); line != null; line = reader
205 					.readLine()) {
206 				line = line.trim();
207 				if (MiscUtils.isEmptyOrWhitespace(line)) {
208 					continue;
209 				}
210 				if (line.startsWith("#")) {
211 					if (!line.startsWith("#EXTINF")) {
212 						continue;
213 					}
214 					try {
215 						final StringTokenizer t = new StringTokenizer(line,
216 								":,");
217 						t.nextToken();
218 						nextDuration = Integer.valueOf(t.nextToken().trim());
219 						nextTitle = t.nextToken().trim();
220 					} catch (final Exception ex) {
221 						// ignore.
222 					}
223 					continue;
224 				}
225 				final TrackMetadataBean.Builder b = new TrackMetadataBean.Builder();
226 				final TrackOriginEnum origin = TrackOriginEnum
227 						.fromLocation(line);
228 				final String url = resolve(origin, parent, line);
229 				b.setLocation(url).setOrigin(origin);
230 				if (nextTitle != null) {
231 					b.setTitle(nextTitle);
232 					nextTitle = null;
233 				}
234 				if (nextDuration != null) {
235 					b.setLength(nextDuration < 0 ? 0 : nextDuration);
236 					nextDuration = null;
237 				}
238 				result.add(b.build(-1));
239 			}
240 			return result;
241 		} finally {
242 			MiscUtils.closeQuietly(in);
243 		}
244 	}
245 
246 	/***
247 	 * Parses the XSPF playlist and returns list of tracks contained in the
248 	 * playlist.
249 	 * 
250 	 * @param in
251 	 *            the playlist stream. the stream is always closed.
252 	 * @param directory
253 	 *            resolve all relative filenames against this directory. May be
254 	 *            <code>null</code> if no resolving will be performed.
255 	 * @return list of tracks
256 	 * @throws IOException
257 	 *             if i/o error occurs.
258 	 * @throws ParseException
259 	 *             if parse exception occurs.
260 	 */
261 	public static List<TrackMetadataBean> parseXspf(final InputStream in,
262 			final String directory) throws IOException, ParseException {
263 		try {
264 			final String parent = directory == null ? null : IOUtils
265 					.removeTrailingSlash(directory);
266 			final List<TrackMetadataBean> result = new ArrayList<TrackMetadataBean>();
267 			IOUtils.parseXML(in, new XspfParser(result, parent));
268 			return result;
269 		} catch (SAXException e) {
270 			final ParseException ex = new ParseException(e.getMessage(), 0);
271 			ex.initCause(e);
272 			throw ex;
273 		} finally {
274 			MiscUtils.closeQuietly(in);
275 		}
276 	}
277 
278 	private static final class XspfParser extends DefaultHandler {
279 		private final List<TrackMetadataBean> result;
280 		private final String parent;
281 		private boolean inTrack = false;
282 		private final static String XSPF_NS = "http://xspf.org/ns/0/";
283 		private String inElement = null;
284 		private final Map<String, StringBuilder> metatags = new HashMap<String, StringBuilder>();
285 
286 		/***
287 		 * Creates new parser instance.
288 		 * 
289 		 * @param result
290 		 *            put all records here.
291 		 * @param parent
292 		 *            the directory where the playlist is located.
293 		 */
294 		public XspfParser(final List<TrackMetadataBean> result,
295 				final String parent) {
296 			this.result = result;
297 			this.parent = parent;
298 		}
299 
300 		@Override
301 		public void characters(char[] ch, int start, int length) {
302 			if (inElement == null || !inTrack) {
303 				return;
304 			}
305 			StringBuilder tagBuilder = metatags.get(inElement);
306 			if (tagBuilder == null) {
307 				tagBuilder = new StringBuilder();
308 				metatags.put(inElement, tagBuilder);
309 			}
310 			tagBuilder.append(ch, start, length);
311 		}
312 
313 		@Override
314 		public void endElement(String uri, String localName, String name)
315 				throws SAXException {
316 			inElement = null;
317 			if ("track".equals(localName)) {
318 				inTrack = false;
319 				// create track and register it.
320 				if (!metatags.containsKey("location")) {
321 					throw new SAXException("Missing <location> element");
322 				}
323 				String location = metatags.get("location").toString().trim();
324 				if (location.startsWith("file://")) {
325 					location = location.substring(7);
326 				}
327 				if (location.startsWith("file:")) {
328 					location = location.substring(5);
329 				}
330 				final TrackMetadataBean.Builder b = new TrackMetadataBean.Builder();
331 				final TrackOriginEnum origin = TrackOriginEnum
332 						.fromLocation(location);
333 				final String url = resolve(origin, parent, location);
334 				b.setLocation(url).setOrigin(origin);
335 				if (metatags.containsKey("creator")) {
336 					b.setArtist(metatags.get("creator").toString().trim());
337 				}
338 				if (metatags.containsKey("album")) {
339 					b.setAlbum(metatags.get("album").toString().trim());
340 				}
341 				if (metatags.containsKey("title")) {
342 					b.setTitle(metatags.get("title").toString().trim());
343 				}
344 				if (metatags.containsKey("duration")) {
345 					try {
346 						b.setLength(Integer.parseInt(metatags.get("duration")
347 								.toString().trim()) / 1000);
348 					} catch (NumberFormatException ex) {
349 						// ignore.
350 					}
351 				}
352 				result.add(b.build(-1));
353 				metatags.clear();
354 			}
355 		}
356 
357 		@Override
358 		public void startElement(String uri, String localName, String name,
359 				Attributes attributes) throws SAXException {
360 			inElement = localName;
361 			if (!XSPF_NS.equals(uri)) {
362 				throw new SAXException("Unsupported playlist type: expected "
363 						+ XSPF_NS + " but got " + uri);
364 			}
365 			if ("track".equals(localName)) {
366 				inTrack = true;
367 			}
368 		}
369 	}
370 
371 	/***
372 	 * Parses the WPL playlist and returns list of tracks contained in the
373 	 * playlist.
374 	 * 
375 	 * @param in
376 	 *            the playlist stream. the stream is always closed.
377 	 * @param directory
378 	 *            resolve all relative filenames against this directory. May be
379 	 *            <code>null</code> if no resolving will be performed.
380 	 * @return list of tracks
381 	 * @throws IOException
382 	 *             if i/o error occurs.
383 	 * @throws ParseException
384 	 *             if parse exception occurs.
385 	 */
386 	public static List<TrackMetadataBean> parseWpl(final InputStream in,
387 			final String directory) throws IOException, ParseException {
388 		try {
389 			final String parent = directory == null ? null : IOUtils
390 					.removeTrailingSlash(directory);
391 			final List<TrackMetadataBean> result = new ArrayList<TrackMetadataBean>();
392 			IOUtils.parseXML(in, new WplParser(result, parent));
393 			return result;
394 		} catch (SAXException e) {
395 			final ParseException ex = new ParseException(e.getMessage(), 0);
396 			ex.initCause(e);
397 			throw ex;
398 		} finally {
399 			MiscUtils.closeQuietly(in);
400 		}
401 	}
402 
403 	private static final class WplParser extends DefaultHandler {
404 		private final List<TrackMetadataBean> result;
405 		private final String parent;
406 
407 		/***
408 		 * Creates new parser instance.
409 		 * 
410 		 * @param result
411 		 *            put tracks here.
412 		 * @param parent
413 		 *            resolve relative paths against this directory.
414 		 */
415 		public WplParser(final List<TrackMetadataBean> result,
416 				final String parent) {
417 			this.result = result;
418 			this.parent = parent;
419 		}
420 
421 		@Override
422 		public void startElement(String uri, String localName, String name,
423 				Attributes attributes) throws SAXException {
424 			if ("media".equals(localName)) {
425 				final String src = attributes.getValue("src");
426 				if (MiscUtils.isEmptyOrWhitespace(src)) {
427 					throw new SAXException("Attribute src missing or invalid");
428 				}
429 				final TrackMetadataBean.Builder b = new TrackMetadataBean.Builder();
430 				final TrackOriginEnum origin = TrackOriginEnum
431 						.fromLocation(src);
432 				final String url = resolve(origin, parent, src);
433 				b.setLocation(url).setOrigin(origin);
434 				result.add(b.build(-1));
435 			}
436 		}
437 	}
438 
439 	/***
440 	 * Parses given playlist.
441 	 * 
442 	 * @param playlist
443 	 *            the playlist to parse.
444 	 * @return list of tracks contained in the playlist
445 	 * @throws IOException
446 	 *             if i/o error occurs
447 	 * @throws ParseException
448 	 *             if the playlist is not well formed or is of unknown type.
449 	 */
450 	public static List<TrackMetadataBean> parse(final File playlist)
451 			throws IOException, ParseException {
452 		final String name = playlist.getName().toLowerCase();
453 		final String parent = playlist.getAbsoluteFile().getParentFile()
454 				.getAbsolutePath();
455 		if (name.endsWith(".m3u")) {
456 			return parseM3u(new FileInputStream(playlist), parent, false);
457 		}
458 		if (name.endsWith(".m3u8")) {
459 			return parseM3u(new FileInputStream(playlist), parent, true);
460 		}
461 		if (name.endsWith(".pls")) {
462 			return parsePls(new FileInputStream(playlist), parent);
463 		}
464 		if (name.endsWith(".xspf")) {
465 			return parseXspf(new FileInputStream(playlist), parent);
466 		}
467 		if (name.endsWith(".wpl")) {
468 			return parseWpl(new FileInputStream(playlist), parent);
469 		}
470 		throw new ParseException("Unknown/unsupported playlist type: " + name,
471 				0);
472 	}
473 
474 	/***
475 	 * Checks if given playlist contains metadata. Currently only XSPF playlists
476 	 * contain metadata.
477 	 * 
478 	 * @param filename
479 	 *            the file to check.
480 	 * @return <code>true</code> if the playlist contains track metadata,
481 	 *         <code>false</code> otherwise.
482 	 */
483 	public static boolean hasMetadata(final String filename) {
484 		return filename.toLowerCase().endsWith(".xspf");
485 	}
486 }