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