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