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
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
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
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("×tamp=");
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
285 retry = false;
286
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 }