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.collection.ampache;
20  
21  import java.io.IOException;
22  import java.io.InputStream;
23  import java.io.UnsupportedEncodingException;
24  import java.net.URL;
25  import java.net.URLEncoder;
26  import java.security.MessageDigest;
27  import java.security.NoSuchAlgorithmException;
28  import java.text.ParseException;
29  import java.util.ArrayList;
30  import java.util.Date;
31  import java.util.List;
32  import java.util.concurrent.locks.ReentrantReadWriteLock;
33  
34  import org.xml.sax.Attributes;
35  import org.xml.sax.ContentHandler;
36  import org.xml.sax.SAXException;
37  import org.xml.sax.helpers.DefaultHandler;
38  
39  import sk.baka.ambient.collection.CategoryEnum;
40  import sk.baka.ambient.collection.CategoryItem;
41  import sk.baka.ambient.collection.TrackMetadataBean;
42  import sk.baka.ambient.collection.TrackOriginEnum;
43  import sk.baka.ambient.commons.IOUtils;
44  import sk.baka.ambient.commons.MiscUtils;
45  import sk.baka.ambient.commons.ThreadSafe;
46  
47  /***
48   * Contains utility methods to communicate with Ampache.
49   * 
50   * @author Martin Vysny
51   */
52  @ThreadSafe
53  public final class AmpacheClient {
54  	/***
55  	 * Holds the server URL.
56  	 */
57  	public final String serverURL;
58  
59  	/***
60  	 * Creates new object instance.
61  	 * 
62  	 * @param serverURL
63  	 *            the URL where Ampache is running, for example
64  	 *            <code>http://localhost/ampache</code>. The object will
65  	 *            automatically add <code>/server/xml.server.php</code> to the
66  	 *            URL.
67  	 */
68  	public AmpacheClient(final String serverURL) {
69  		this.serverURL = serverURL;
70  	}
71  
72  	/***
73  	 * Connects to the Ampache server.
74  	 * 
75  	 * @param user
76  	 *            optional user
77  	 * @param password
78  	 *            required password.
79  	 * @return the server information object. Must not be modified.
80  	 * @throws AmpacheException
81  	 *             if Ampache rejects to process the request.
82  	 * @throws IOException
83  	 *             if i/o error occurs.
84  	 * @throws SAXException
85  	 */
86  	public AmpacheInfo connect(final String user, final String password)
87  			throws AmpacheException, IOException, SAXException {
88  		lock.writeLock().lock();
89  		try {
90  			this.password = password;
91  			final String time = String.valueOf(System.currentTimeMillis() / 1000L);
92  			final String md5 = computePassphrase(password, time);
93  			// connect to the server
94  			final ConnectHandler handler = new ConnectHandler();
95  			processRequest(handler, "handshake", null, time, md5, user, true);
96  			info = handler.info.build();
97  			return info;
98  		} finally {
99  			lock.writeLock().unlock();
100 		}
101 	}
102 
103 	/***
104 	 * Computes Ampache passphrase for given time and password.
105 	 * 
106 	 * @param password
107 	 *            the password
108 	 * @param time
109 	 *            the time
110 	 * @return the passphrase, a MD5 hash.
111 	 */
112 	public static String computePassphrase(final String password,
113 			final String time) {
114 		final String timepassword = time + password;
115 		// compute MD5
116 		final MessageDigest m;
117 		try {
118 			m = MessageDigest.getInstance("MD5");
119 		} catch (NoSuchAlgorithmException e) {
120 			throw new RuntimeException(e);
121 		}
122 		m.update(timepassword.getBytes());
123 		final byte[] digest = m.digest();
124 		return MiscUtils.toHexa(digest);
125 	}
126 
127 	/***
128 	 * Connected with this password.
129 	 */
130 	private String password;
131 
132 	/***
133 	 * Current connection information.
134 	 */
135 	private AmpacheInfo info;
136 
137 	/***
138 	 * Locks access to {@link #password} and {@link #info}.
139 	 */
140 	private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
141 
142 	/***
143 	 * Returns server information.
144 	 * 
145 	 * @return server information object. Must not be modified.
146 	 *         <code>null</code> if not connected.
147 	 */
148 	public AmpacheInfo getInfo() {
149 		return info;
150 	}
151 
152 	private final static class ConnectHandler extends ErrorHandlingHandler {
153 		public AmpacheInfo.Builder info = new AmpacheInfo.Builder();
154 
155 		@Override
156 		public void endElement(String uri, String localName, String name)
157 				throws SAXException {
158 			super.endElement(uri, localName, name);
159 			if ("auth".equals(localName)) {
160 				info.token = textContents;
161 			} else if ("version".equals(localName)) {
162 				info.apiVersion = textContents;
163 			} else if ("update".equals(localName)) {
164 				info.lastUpdate = parseRFC2822Date(textContents);
165 			} else if ("add".equals(localName)) {
166 				info.lastAdd = parseRFC2822Date(textContents);
167 			} else if ("songs".equals(localName)) {
168 				info.songs = parseInt(textContents);
169 			} else if ("artists".equals(localName)) {
170 				info.artists = parseInt(textContents);
171 			} else if ("albums".equals(localName)) {
172 				info.albums = parseInt(textContents);
173 			}
174 		}
175 	}
176 
177 	private static Date parseRFC2822Date(final String date) throws SAXException {
178 		try {
179 			return MiscUtils.parseRFC2822Date(date);
180 		} catch (ParseException e) {
181 			throw new SAXException(e);
182 		}
183 	}
184 
185 	private static int parseInt(final String number) throws SAXException {
186 		try {
187 			return Integer.parseInt(number);
188 		} catch (NumberFormatException e) {
189 			throw new SAXException(e);
190 		}
191 	}
192 
193 	/***
194 	 * Sends request to the Ampache and processes it using given
195 	 * {@link ContentHandler}.
196 	 * 
197 	 * @param action
198 	 *            the ampache action to perform.
199 	 * @param filter
200 	 *            if not <code>null</code> then sets the filter parameter.
201 	 * @throws IOException
202 	 *             if i/o error occurs.
203 	 * @throws SAXException
204 	 *             if the XML is malformed.
205 	 * @throws AmpacheException
206 	 *             Ampache server error.
207 	 */
208 	private void processRequest(final ErrorHandlingHandler handler,
209 			final String action, final String filter) throws IOException,
210 			SAXException, AmpacheException {
211 		processRequest(handler, action, filter, null, null, null, false);
212 	}
213 
214 	private void checkConnected() {
215 		lock.readLock().lock();
216 		if (info == null) {
217 			lock.readLock().unlock();
218 			throw new IllegalStateException("Not connected");
219 		}
220 		lock.readLock().unlock();
221 	}
222 
223 	/***
224 	 * Sends request to the Ampache and processes it using given
225 	 * {@link ContentHandler}.
226 	 * 
227 	 * @param action
228 	 *            the ampache action to perform.
229 	 * @param filter
230 	 *            if not <code>null</code> then sets the filter parameter.
231 	 * @param timestamp
232 	 *            if not <code>null</code> then sets the timestamp parameter.
233 	 * @param auth
234 	 *            if not <code>null</code> then sets the auth parameter.
235 	 * @param username
236 	 *            if not <code>null</code> then sets the username parameter.
237 	 * @throws IOException
238 	 *             if i/o error occurs.
239 	 * @throws SAXException
240 	 *             if the XML is malformed.
241 	 * @throws AmpacheException
242 	 *             Ampache server error.
243 	 */
244 	private void processRequest(final ErrorHandlingHandler handler,
245 			final String action, final String filter, final String timestamp,
246 			final String auth, final String username,
247 			final boolean performingConnect) throws IOException, SAXException,
248 			AmpacheException {
249 		try {
250 			boolean retry = false;
251 			boolean alreadyRetried = false;
252 			do {
253 				// build the request URL
254 				final StringBuilder url = new StringBuilder(serverURL);
255 				url.append("/server/xml.server.php?action=");
256 				url.append(action);
257 				if (filter != null) {
258 					url.append("&filter=");
259 					url.append(URLEncoder.encode(filter, "UTF-8"));
260 				}
261 				if (timestamp != null) {
262 					url.append("&timestamp=");
263 					url.append(URLEncoder.encode(timestamp, "UTF-8"));
264 				}
265 				if (auth != null) {
266 					url.append("&auth=");
267 					url.append(URLEncoder.encode(auth, "UTF-8"));
268 				} else {
269 					lock.readLock().lock();
270 					try {
271 						if (info != null) {
272 							url.append("&auth=");
273 							url.append(info.token);
274 						}
275 					} finally {
276 						lock.readLock().unlock();
277 					}
278 				}
279 				if (!MiscUtils.isEmptyOrWhitespace(username)) {
280 					url.append("&user=");
281 					url.append(URLEncoder.encode(username, "UTF-8"));
282 				}
283 				final URL u = new URL(url.toString());
284 				// IOUtils.cat(new InputStreamReader(u.openStream(), "UTF-8"));
285 				retry = false;
286 				// parse result
287 				final InputStream in = u.openStream();
288 				try {
289 					IOUtils.parseXML(in, handler);
290 				} catch (SAXException e) {
291 					if (e.getException() instanceof AmpacheException) {
292 						final String errorCode = ((AmpacheException) e
293 								.getException()).errorCode;
294 						if ("401".equals(errorCode) && !alreadyRetried
295 								&& !performingConnect) {
296 							lock.readLock().lock();
297 							final String pass = password;
298 							lock.readLock().unlock();
299 							connect(username, pass);
300 							retry = true;
301 							alreadyRetried = true;
302 						} else {
303 							throw (AmpacheException) e.getException();
304 						}
305 					} else {
306 						throw e;
307 					}
308 				}
309 			} while (retry);
310 		} catch (UnsupportedEncodingException e) {
311 			throw new RuntimeException(e);
312 		}
313 	}
314 
315 	/***
316 	 * Returns artists from Ampache.
317 	 * 
318 	 * @param substring
319 	 *            optional substring which the album name must contain.
320 	 * 
321 	 * @return list of artists.
322 	 * @throws AmpacheException
323 	 * @throws SAXException
324 	 * @throws IOException
325 	 */
326 	public List<CategoryItem> getArtists(final String substring)
327 			throws IOException, SAXException, AmpacheException {
328 		checkConnected();
329 		final GetArtistsHandler h = new GetArtistsHandler(null);
330 		processRequest(h, "artists", substring);
331 		return h.artists;
332 	}
333 
334 	/***
335 	 * Returns songs for given artist.
336 	 * 
337 	 * @param artistId
338 	 *            the artist ID.
339 	 * 
340 	 * @return list of tracks.
341 	 * @throws AmpacheException
342 	 * @throws SAXException
343 	 * @throws IOException
344 	 */
345 	public List<TrackMetadataBean> getArtistSongs(final String artistId)
346 			throws IOException, SAXException, AmpacheException {
347 		checkConnected();
348 		final GetSongsHandler h = new GetSongsHandler();
349 		processRequest(h, "artist_songs", artistId);
350 		return h.songs;
351 	}
352 
353 	/***
354 	 * Returns albums for given artist.
355 	 * 
356 	 * @param artistId
357 	 *            the artist ID.
358 	 * @param substring
359 	 *            optional substring which the album name must contain.
360 	 * 
361 	 * @return list of albums.
362 	 * @throws AmpacheException
363 	 * @throws SAXException
364 	 * @throws IOException
365 	 */
366 	public List<CategoryItem> getArtistAlbums(final String artistId,
367 			final String substring) throws IOException, SAXException,
368 			AmpacheException {
369 		checkConnected();
370 		final GetAlbumsHandler h = new GetAlbumsHandler(substring);
371 		processRequest(h, "artist_albums", artistId);
372 		return h.albums;
373 	}
374 
375 	private final static class GetArtistsHandler extends ErrorHandlingHandler {
376 		public final List<CategoryItem> artists = new ArrayList<CategoryItem>();
377 		private CategoryItem.Builder item;
378 		private final String substring;
379 
380 		public GetArtistsHandler(final String substring) {
381 			this.substring = substring == null ? null : substring.toLowerCase();
382 		}
383 
384 		@Override
385 		public void startElement(String uri, String localName, String name,
386 				Attributes attributes) throws SAXException {
387 			super.startElement(uri, localName, name, attributes);
388 			if ("artist".equals(localName)) {
389 				item = new CategoryItem.Builder();
390 				item.id = attributes.getValue("id");
391 				item.category = CategoryEnum.Artist;
392 			}
393 		}
394 
395 		@Override
396 		public void endElement(String uri, String localName, String name)
397 				throws SAXException {
398 			super.endElement(uri, localName, name);
399 			if ("name".equals(localName)) {
400 				item.name = textContents;
401 			} else if ("albums".equals(localName)) {
402 				item.albums = parseInt(textContents);
403 			} else if ("songs".equals(localName)) {
404 				item.songs = parseInt(textContents);
405 			} else if ("artist".equals(localName)) {
406 				if ((substring == null)
407 						|| (item.name.toLowerCase().contains(substring))) {
408 					artists.add(item.build());
409 				}
410 			}
411 		}
412 	}
413 
414 	private final static class GetSongsHandler extends ErrorHandlingHandler {
415 		public final List<TrackMetadataBean> songs = new ArrayList<TrackMetadataBean>();
416 		private TrackMetadataBean.Builder builder;
417 
418 		@Override
419 		public void startElement(String uri, String localName, String name,
420 				Attributes attributes) throws SAXException {
421 			super.startElement(uri, localName, name, attributes);
422 			if ("song".equals(localName)) {
423 				builder = new TrackMetadataBean.Builder();
424 				builder.setOrigin(TrackOriginEnum.Ampache);
425 				id = attributes.getValue("id");
426 			}
427 		}
428 
429 		private String id;
430 
431 		@Override
432 		public void endElement(String uri, String localName, String name)
433 				throws SAXException {
434 			super.endElement(uri, localName, name);
435 			if ("title".equals(localName)) {
436 				builder.setTitle(textContents);
437 			} else if ("artist".equals(localName)) {
438 				builder.setArtist(textContents);
439 			} else if ("album".equals(localName)) {
440 				builder.setAlbum(textContents);
441 			} else if ("genre".equals(localName)) {
442 				builder.setGenre(textContents);
443 			} else if ("track".equals(localName)) {
444 				builder.setTrackNumber(textContents);
445 			} else if ("time".equals(localName)) {
446 				builder.setLength(parseInt(textContents));
447 			} else if ("url".equals(localName)) {
448 				builder.setLocation(textContents);
449 			} else if ("size".equals(localName)) {
450 				builder.setFileSize(parseInt(textContents));
451 			} else if ("song".equals(localName)) {
452 				songs.add(builder.build(parseInt(id)));
453 			}
454 		}
455 	}
456 
457 	/***
458 	 * Returns albums from Ampache.
459 	 * 
460 	 * @param substring
461 	 *            optional substring which the album name must contain.
462 	 * @return list of albums.
463 	 * @throws AmpacheException
464 	 * @throws SAXException
465 	 * @throws IOException
466 	 */
467 	public List<CategoryItem> getAlbums(final String substring)
468 			throws IOException, SAXException, AmpacheException {
469 		checkConnected();
470 		final GetAlbumsHandler h = new GetAlbumsHandler(null);
471 		processRequest(h, "albums", substring);
472 		return h.albums;
473 	}
474 
475 	/***
476 	 * Returns tracks for given album.
477 	 * 
478 	 * @param albumId
479 	 *            the album ID.
480 	 * @return list of tracks.
481 	 * @throws AmpacheException
482 	 * @throws SAXException
483 	 * @throws IOException
484 	 */
485 	public List<TrackMetadataBean> getAlbumSongs(final String albumId)
486 			throws IOException, SAXException, AmpacheException {
487 		checkConnected();
488 		final GetSongsHandler h = new GetSongsHandler();
489 		processRequest(h, "album_songs", albumId);
490 		return h.songs;
491 	}
492 
493 	private final static class GetAlbumsHandler extends ErrorHandlingHandler {
494 		public final List<CategoryItem> albums = new ArrayList<CategoryItem>();
495 		private CategoryItem.Builder item;
496 		private final String substring;
497 
498 		public GetAlbumsHandler(final String substring) {
499 			this.substring = substring == null ? null : substring.toLowerCase();
500 		}
501 
502 		@Override
503 		public void startElement(String uri, String localName, String name,
504 				Attributes attributes) throws SAXException {
505 			super.startElement(uri, localName, name, attributes);
506 			if ("album".equals(localName)) {
507 				item = new CategoryItem.Builder();
508 				item.id = attributes.getValue("id");
509 				item.category = CategoryEnum.Album;
510 			}
511 		}
512 
513 		@Override
514 		public void endElement(String uri, String localName, String name)
515 				throws SAXException {
516 			super.endElement(uri, localName, name);
517 			if ("name".equals(localName)) {
518 				item.name = textContents;
519 			} else if ("year".equals(localName)) {
520 				item.year = textContents;
521 			} else if ("tracks".equals(localName)) {
522 				item.songs = parseInt(textContents);
523 			} else if ("album".equals(localName)) {
524 				if ((substring == null)
525 						|| item.name.toLowerCase().contains(substring)) {
526 					albums.add(item.build());
527 				}
528 			}
529 		}
530 	}
531 
532 	/***
533 	 * Returns albums from Ampache.
534 	 * 
535 	 * @param substring
536 	 *            optional substring which the album name must contain.
537 	 * 
538 	 * @return list of albums.
539 	 * @throws AmpacheException
540 	 * @throws SAXException
541 	 * @throws IOException
542 	 */
543 	public List<CategoryItem> getGenres(final String substring)
544 			throws IOException, SAXException, AmpacheException {
545 		checkConnected();
546 		final GetGenresHandler h = new GetGenresHandler();
547 		processRequest(h, "genres", substring);
548 		return h.genres;
549 	}
550 
551 	/***
552 	 * Returns artists for given genre.
553 	 * 
554 	 * @param genreId
555 	 *            the genre id.
556 	 * @param substring
557 	 *            optional substring which the album name must contain.
558 	 * @return list of artists.
559 	 * @throws AmpacheException
560 	 * @throws SAXException
561 	 * @throws IOException
562 	 */
563 	public List<CategoryItem> getGenreArtists(final String genreId,
564 			final String substring) throws IOException, SAXException,
565 			AmpacheException {
566 		checkConnected();
567 		final GetArtistsHandler h = new GetArtistsHandler(substring);
568 		processRequest(h, "genre_artists", genreId);
569 		return h.artists;
570 	}
571 
572 	/***
573 	 * Returns albums for given genre.
574 	 * 
575 	 * @param genreId
576 	 *            the genre id.
577 	 * @param substring
578 	 *            optional substring which the album name must contain.
579 	 * 
580 	 * @return list of albums.
581 	 * @throws AmpacheException
582 	 * @throws SAXException
583 	 * @throws IOException
584 	 */
585 	public List<CategoryItem> getGenreAlbums(final String genreId,
586 			final String substring) throws IOException, SAXException,
587 			AmpacheException {
588 		checkConnected();
589 		final GetAlbumsHandler h = new GetAlbumsHandler(substring);
590 		processRequest(h, "genre_albums", genreId);
591 		return h.albums;
592 	}
593 
594 	/***
595 	 * Returns songs for given genre.
596 	 * 
597 	 * @param genreId
598 	 *            the genre id.
599 	 * 
600 	 * @return list of songs.
601 	 * @throws AmpacheException
602 	 * @throws SAXException
603 	 * @throws IOException
604 	 */
605 	public List<TrackMetadataBean> getGenreSongs(final String genreId)
606 			throws IOException, SAXException, AmpacheException {
607 		checkConnected();
608 		final GetSongsHandler h = new GetSongsHandler();
609 		processRequest(h, "genre_songs", genreId);
610 		return h.songs;
611 	}
612 
613 	private final static class GetGenresHandler extends ErrorHandlingHandler {
614 		public final List<CategoryItem> genres = new ArrayList<CategoryItem>();
615 		private CategoryItem.Builder item;
616 
617 		@Override
618 		public void startElement(String uri, String localName, String name,
619 				Attributes attributes) throws SAXException {
620 			super.startElement(uri, localName, name, attributes);
621 			if ("genre".equals(localName)) {
622 				item = new CategoryItem.Builder();
623 				item.id = attributes.getValue("id");
624 				item.category = CategoryEnum.Genre;
625 			}
626 		}
627 
628 		@Override
629 		public void endElement(String uri, String localName, String name)
630 				throws SAXException {
631 			super.endElement(uri, localName, name);
632 			if ("name".equals(localName)) {
633 				item.name = textContents;
634 			} else if ("albums".equals(localName)) {
635 				item.albums = parseInt(textContents);
636 			} else if ("songs".equals(localName)) {
637 				item.songs = parseInt(textContents);
638 			} else if ("genre".equals(localName)) {
639 				genres.add(item.build());
640 			}
641 		}
642 	}
643 
644 	/***
645 	 * Superclass for all handlers handling Ampache output. Handles errors.
646 	 * 
647 	 * @author Martin Vysny
648 	 */
649 	protected static class ErrorHandlingHandler extends DefaultHandler {
650 
651 		private boolean wasFirstElement = false;
652 
653 		@Override
654 		public void startDocument() {
655 			wasFirstElement = false;
656 			errorCode = null;
657 		}
658 
659 		private String errorCode = null;
660 
661 		/***
662 		 * Text contents of the last element. Intended to be read in the
663 		 * {@link #endElement(String, String, String)} event.
664 		 */
665 		private final StringBuilder lastElementContents = new StringBuilder();
666 
667 		/***
668 		 * Text contents of the last element. Intended to be read in the
669 		 * {@link #endElement(String, String, String)} event.
670 		 */
671 		protected String textContents;
672 
673 		@Override
674 		public void startElement(String uri, String localName, String name,
675 				Attributes attributes) throws SAXException {
676 			lastElementContents.delete(0, lastElementContents.length());
677 			if (!wasFirstElement) {
678 				if (!"root".equals(localName)) {
679 					throw new SAXException("No <root> element found: "
680 							+ localName);
681 				}
682 			}
683 			wasFirstElement = true;
684 			if ("error".equals(localName)) {
685 				errorCode = attributes.getValue("code");
686 			}
687 		}
688 
689 		@Override
690 		public void endElement(String uri, String localName, String name)
691 				throws SAXException {
692 			if (Thread.currentThread().isInterrupted()) {
693 				throw new RuntimeException("interrupted");
694 			}
695 			textContents = lastElementContents.toString().trim();
696 			lastElementContents.delete(0, lastElementContents.length());
697 			if ("error".equals(localName)) {
698 				throw new SAXException(new AmpacheException(errorCode,
699 						textContents));
700 			}
701 		}
702 
703 		@Override
704 		public void characters(char[] ch, int start, int length) {
705 			lastElementContents.append(ch, start, length);
706 		}
707 	}
708 
709 	/***
710 	 * Search song which Song Title, Artist Name, Album Name or Genre Name
711 	 * contains given substring.
712 	 * 
713 	 * @param substring
714 	 *            the substring to search for.
715 	 * @return the list of tracks, never <code>null</code>, may be empty.
716 	 *         Sorted in no particular order.
717 	 * @throws IOException
718 	 * @throws SAXException
719 	 * @throws AmpacheException
720 	 */
721 	public List<TrackMetadataBean> searchTracks(String substring)
722 			throws IOException, SAXException, AmpacheException {
723 		checkConnected();
724 		final GetSongsHandler h = new GetSongsHandler();
725 		processRequest(h, "search_songs", substring);
726 		return h.songs;
727 	}
728 }