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
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
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
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
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
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 }