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.OutputStream;
24 import java.io.OutputStreamWriter;
25 import java.io.Writer;
26 import java.net.Socket;
27 import java.net.URLDecoder;
28 import java.util.HashMap;
29 import java.util.List;
30 import java.util.Map;
31
32 import sk.baka.ambient.AmbientApplication;
33 import sk.baka.ambient.R;
34 import sk.baka.ambient.collection.AbstractAudio;
35 import sk.baka.ambient.collection.CategoryEnum;
36 import sk.baka.ambient.collection.CategoryItem;
37 import sk.baka.ambient.collection.CollectionException;
38 import sk.baka.ambient.collection.ICollection;
39 import sk.baka.ambient.collection.Statistics;
40 import sk.baka.ambient.collection.TrackMetadataBean;
41 import sk.baka.ambient.commons.IOUtils;
42 import sk.baka.ambient.commons.MiscUtils;
43 import sk.baka.ambient.commons.NullArgumentException;
44 import sk.baka.ambient.commons.ServerHttpException;
45 import sk.baka.ambient.commons.SocketServer;
46 import android.util.Log;
47
48 /***
49 * Provides the Ampache XML API services over a network. Hosts local tracks
50 * only.
51 *
52 * @author Martin Vysny
53 */
54 public final class AmpacheServer extends SocketServer {
55 /***
56 * The port to listen on.
57 */
58 public static final int PORT = 5413;
59
60 /***
61 * Currently valid sessions.
62 */
63 private final Map<String, Object> validSessions = new HashMap<String, Object>();
64 /***
65 * The current password.
66 */
67 private volatile String password;
68
69 /***
70 * The backend collection.
71 */
72 private final ICollection collection;
73
74 /***
75 * IP address of this device, must not be <code>null</code>.
76 */
77 public volatile String myIP = "localhost";
78
79 /***
80 * Creates new server instance. The server is stopped by default.
81 *
82 * @param password
83 * initial password. If <code>null</code> then the security is
84 * disabled.
85 * @param collection
86 * The backend collection, must not be <code>null</code>.
87 */
88 public AmpacheServer(final String password, final ICollection collection) {
89 super();
90 if (collection == null) {
91 throw new NullArgumentException("collection");
92 }
93 this.password = password;
94 this.collection = collection;
95 }
96
97 /***
98 * Resets the server and sets new credentials required. The server remains
99 * started if it was started before. Does nothing if the password is the
100 * same as before.
101 *
102 * @param password
103 * the password
104 */
105 public void reset(final String password) {
106 if (!MiscUtils.nullEquals(this.password, password)) {
107 synchronized (validSessions) {
108 validSessions.clear();
109 }
110 this.password = password;
111 }
112 }
113
114 @Override
115 protected void handleRequest(Socket socket, InputStream in, OutputStream out)
116 throws IOException, ServerHttpException, InterruptedException {
117 final Writer socketOutWriter = new OutputStreamWriter(out, "UTF-8");
118 try {
119 final IOUtils.HttpRequest request = initConnection(in, out,
120 socketOutWriter);
121 if (request == null) {
122 return;
123 }
124 final String action = request.query.get("action");
125 if (action == null) {
126 throw new AmpacheException("405", "Missing parameter 'action'");
127 }
128 if ("handshake".equals(action)) {
129 handshake(request.query.get("auth"), request.query
130 .get("timestamp"), socketOutWriter);
131 return;
132 }
133 checkAuthAmpache(request.query);
134 if ("artists".equals(action)) {
135 artists(request.query, socketOutWriter);
136 } else if ("artist_songs".equals(action)) {
137 artistSongs(request.query, socketOutWriter);
138 } else if ("artist_albums".equals(action)) {
139 artistAlbums(request.query, socketOutWriter);
140 } else if ("albums".equals(action)) {
141 albums(request.query, socketOutWriter);
142 } else if ("album_songs".equals(action)) {
143 albumSongs(request.query, socketOutWriter);
144 } else if ("genres".equals(action)) {
145 genres(request.query, socketOutWriter);
146 } else if ("genre_artists".equals(action)) {
147 genreArtists(request.query, socketOutWriter);
148 } else if ("genre_albums".equals(action)) {
149 genreAlbums(request.query, socketOutWriter);
150 } else if ("genre_songs".equals(action)) {
151 genreSongs(request.query, socketOutWriter);
152 } else if ("playlists".equals(action)) {
153 playlist(socketOutWriter);
154 } else if ("playlist".equals(action)) {
155 playlist(socketOutWriter);
156 } else if ("playlist_songs".equals(action)) {
157 playlist(socketOutWriter);
158 } else if ("search_songs".equals(action)) {
159 searchSongs(request.query, socketOutWriter);
160 } else {
161 throw new AmpacheException("405", "Unknown action '" + action
162 + "'");
163 }
164 } catch (AmpacheException ex) {
165 Log.e(AmpacheServer.class.getSimpleName(), "Ampache error", ex);
166 writeAmpacheError(ex.errorCode, ex.errorMsg, socketOutWriter);
167 } catch (RuntimeException ex) {
168 if (!Thread.currentThread().isInterrupted()) {
169 final AmbientApplication app = AmbientApplication.getInstance();
170 app.error(AmpacheServer.class, true, app
171 .getString(R.string.ampacheServerError), ex);
172 writeAmpacheError("401", "Internal error: " + ex.getMessage(),
173 socketOutWriter);
174 }
175 } catch (CollectionException ex) {
176 if (!Thread.currentThread().isInterrupted()) {
177 final AmbientApplication app = AmbientApplication.getInstance();
178 app.error(AmpacheServer.class, true, app
179 .getString(R.string.ampacheServerError), ex);
180 writeAmpacheError("401", "Internal error: " + ex.getMessage(),
181 socketOutWriter);
182 }
183 } finally {
184 MiscUtils.closeQuietly(socketOutWriter);
185 }
186 }
187
188 private void searchSongs(Map<String, String> params, Writer w)
189 throws CollectionException, InterruptedException, IOException {
190 final List<TrackMetadataBean> items = collection.searchTracks(params
191 .get("filter"));
192 writeSongs(w, items, params);
193 }
194
195 private void playlist(Writer w) throws IOException {
196 w.write("<root>\n</root>\n");
197 }
198
199 private void genreSongs(Map<String, String> params, Writer w)
200 throws CollectionException, InterruptedException, IOException {
201 final String genreId = params.get("filter");
202 CategoryItem item = collection.deserializeItem(genreId);
203 item = setCategory(item, CategoryEnum.Genre);
204 final List<TrackMetadataBean> items = collection.getTracks(item);
205 writeSongs(w, items, params);
206 }
207
208 private void genreAlbums(Map<String, String> params, Writer w)
209 throws IOException, CollectionException, InterruptedException {
210 final String genreId = params.get("filter");
211 CategoryItem item = collection.deserializeItem(genreId);
212 item = setCategory(item, CategoryEnum.Genre);
213 final List<CategoryItem> items = collection.getCategoryList(
214 CategoryEnum.Album, item, null, false);
215 writeAlbums(w, items);
216 }
217
218 private void genreArtists(Map<String, String> params, Writer w)
219 throws CollectionException, InterruptedException, IOException {
220 final String genreId = params.get("filter");
221 CategoryItem item = collection.deserializeItem(genreId);
222 item = setCategory(item, CategoryEnum.Genre);
223 final List<CategoryItem> items = collection.getCategoryList(
224 CategoryEnum.Artist, item, null, false);
225 writeArtists(w, items);
226 }
227
228 private void genres(Map<String, String> params, Writer w)
229 throws CollectionException, InterruptedException, IOException {
230 final List<CategoryItem> items = collection.getCategoryList(
231 CategoryEnum.Genre, null, params.get("filter"), false);
232 writeGenres(w, items);
233 }
234
235 private void writeGenres(Writer w, List<CategoryItem> items)
236 throws IOException {
237 w.write("<root>\n");
238 for (final CategoryItem item : items) {
239 w.write("\t<genre id=\"" + escape(collection.serializeItem(item))
240 + "\">\n");
241 w.write("\t\t<name>" + escape(item.name) + "</name>\n");
242 if (item.albums > 0) {
243 w.write("\t\t<albums>" + item.albums + "</albums>\n");
244 }
245 if (item.songs > 0) {
246 w.write("\t\t<songs>" + item.songs + "</songs>\n");
247 }
248 w.write("\t</genre>\n");
249 }
250 w.write("</root>\n");
251 }
252
253 private void albumSongs(Map<String, String> params, Writer w)
254 throws IOException, CollectionException, InterruptedException {
255 final String albumId = params.get("filter");
256 CategoryItem item = collection.deserializeItem(albumId);
257 item = setCategory(item, CategoryEnum.Album);
258 final List<TrackMetadataBean> items = collection.getTracks(item);
259 writeSongs(w, items, params);
260 }
261
262 private void albums(Map<String, String> params, Writer w)
263 throws CollectionException, InterruptedException, IOException {
264 final List<CategoryItem> items = collection.getCategoryList(
265 CategoryEnum.Album, null, params.get("filter"), false);
266 writeAlbums(w, items);
267 }
268
269 private void artistAlbums(Map<String, String> params, Writer socketOutWriter)
270 throws CollectionException, InterruptedException, IOException {
271 final String artistId = params.get("filter");
272 CategoryItem item = collection.deserializeItem(artistId);
273 item = setCategory(item, CategoryEnum.Artist);
274 final List<CategoryItem> items = collection.getCategoryList(
275 CategoryEnum.Album, item, null, false);
276 writeAlbums(socketOutWriter, items);
277 }
278
279 private void writeAlbums(Writer w, List<CategoryItem> items)
280 throws IOException {
281 w.write("<root>\n");
282 for (final CategoryItem item : items) {
283 w.write("\t<album id=\"" + escape(collection.serializeItem(item))
284 + "\">\n");
285 w.write("\t\t<name>" + escape(item.name) + "</name>\n");
286 if (!MiscUtils.isEmptyOrWhitespace(item.year)) {
287 w.write("\t\t<year>" + escape(item.year) + "</year>\n");
288 }
289 if (item.songs > 0) {
290 w.write("\t\t<tracks>" + item.songs + "</tracks>\n");
291 }
292 w.write("\t</album>\n");
293 }
294 w.write("</root>\n");
295 }
296
297 private void artistSongs(Map<String, String> params, Writer w)
298 throws CollectionException, InterruptedException, IOException {
299 final String artistId = params.get("filter");
300 CategoryItem item = collection.deserializeItem(artistId);
301 item = setCategory(item, CategoryEnum.Artist);
302 final List<TrackMetadataBean> tracks = collection.getTracks(item);
303 writeSongs(w, tracks, params);
304 }
305
306 private CategoryItem setCategory(CategoryItem item, CategoryEnum category) {
307 return new CategoryItem.Builder().getData(item).setCategory(category)
308 .build();
309 }
310
311 private void writeSongs(Writer w, List<TrackMetadataBean> tracks,
312 final Map<String, String> params) throws IOException {
313 final String auth = params.get("auth");
314 w.write("<root>\n");
315 for (final TrackMetadataBean track : tracks) {
316 w.write("\t<song id=\"" + track.getTrackId() + "\">\n");
317 w.write("\t\t<title>" + escape(track.getDisplayableName())
318 + "</title>\n");
319 if (!MiscUtils.isEmptyOrWhitespace(track.getArtist())) {
320 w.write("\t\t<artist>" + escape(track.getArtist())
321 + "</artist>\n");
322 }
323 if (!MiscUtils.isEmptyOrWhitespace(track.getAlbum())) {
324 w
325 .write("\t\t<album>" + escape(track.getAlbum())
326 + "</album>\n");
327 }
328 if (!MiscUtils.isEmptyOrWhitespace(track.getGenre())) {
329 w
330 .write("\t\t<genre>" + escape(track.getGenre())
331 + "</genre>\n");
332 }
333 if (!MiscUtils.isEmptyOrWhitespace(track.getTrackNumber())) {
334 w.write("\t\t<track>" + escape(track.getTrackNumber())
335 + "</track>\n");
336 }
337 if (track.getLength() > 0) {
338 w.write("\t\t<time>" + track.getLength() + "</time>\n");
339 }
340 w.write("\t\t<url>http://" + myIP + ":" + PORT + "/ampache/play/"
341 + escape(IOUtils.encodeURL(track.getLocation())));
342 if (auth != null) {
343 w.write("?auth=" + auth);
344 }
345 w.write("</url>\n");
346 w.write("\t\t<size>" + track.getFileSize() + "</size>\n");
347 w.write("\t</song>\n");
348 }
349 w.write("</root>\n");
350 }
351
352 private void artists(Map<String, String> params, Writer w)
353 throws IOException, CollectionException, InterruptedException {
354 final List<CategoryItem> items = collection.getCategoryList(
355 CategoryEnum.Artist, null, params.get("filter"), false);
356 writeArtists(w, items);
357 }
358
359 private void writeArtists(Writer w, final List<CategoryItem> items)
360 throws IOException {
361 w.write("<root>\n");
362 for (final CategoryItem item : items) {
363 w.write("\t<artist id=\"" + escape(collection.serializeItem(item))
364 + "\">\n");
365 w.write("\t\t<name>" + escape(item.name) + "</name>\n");
366 if (item.albums > 0) {
367 w.write("\t\t<albums>" + item.albums + "</albums>\n");
368 }
369 if (item.songs > 0) {
370 w.write("\t\t<songs>" + item.songs + "</songs>\n");
371 }
372 w.write("\t</artist>\n");
373 }
374 w.write("</root>\n");
375 }
376
377 private String escape(String string) {
378 return MiscUtils.emptyIfNull(string).replace("&", "&").replace("<",
379 "<").replace(">", ">");
380 }
381
382 private void handshake(String auth, String timestamp, Writer w)
383 throws IOException, CollectionException, InterruptedException,
384 AmpacheException {
385 if (password != null) {
386 final String expectedAuth = AmpacheClient.computePassphrase(
387 password, timestamp);
388 if (!expectedAuth.equals(auth)) {
389 MiscUtils.sysout("Expected " + expectedAuth + " but was "
390 + auth);
391 throw new AmpacheException("403", "Invalid password");
392 }
393 }
394
395 synchronized (validSessions) {
396 validSessions.put(auth, MiscUtils.NULL);
397 }
398 final Statistics stats = collection.getStatistics();
399 w.write("<root>\n");
400 w.write("\t<auth>" + auth + "</auth>\n");
401 w.write("\t<version>340001</version>\n");
402 w.write("\t<songs>" + stats.tracks + "</songs>\n");
403 w.write("\t<artists>" + stats.artists + "</artists>\n");
404 w.write("\t<albums>" + stats.albums + "</albums>\n");
405 w.write("</root>\n");
406 }
407
408 private void checkAuthAmpache(final Map<String, String> params)
409 throws AmpacheException {
410 if (password == null) {
411 return;
412 }
413 final String auth = params.get("auth");
414 if (auth == null) {
415 throw new AmpacheException("405", "Missing the 'auth' parameter");
416 }
417 final boolean authSuccessfull;
418 synchronized (validSessions) {
419 authSuccessfull = validSessions.containsKey(auth);
420 }
421 if (!authSuccessfull) {
422 throw new AmpacheException("401", "Unknown token " + auth);
423 }
424 }
425
426 private void checkAuth(final Map<String, String> params)
427 throws ServerHttpException {
428 if (password == null) {
429 return;
430 }
431 final String auth = params.get("auth");
432 if (auth == null) {
433 throw new ServerHttpException(403, "Missing the 'auth' parameter");
434 }
435 final boolean authSuccessfull;
436 synchronized (validSessions) {
437 authSuccessfull = validSessions.containsKey(auth);
438 }
439 if (!authSuccessfull) {
440 throw new ServerHttpException(403, "Unknown token " + auth);
441 }
442 }
443
444 private AbstractAudio fileFromPath(final String path)
445 throws ServerHttpException {
446 if (!path.startsWith("/ampache/play/")) {
447 throw new IllegalArgumentException(path
448 + " must start with /ampache/play/");
449 }
450 final String fname = URLDecoder.decode(path.substring("/ampache/play/"
451 .length()));
452 final AbstractAudio audio = AbstractAudio.fromUri(fname);
453 if (!audio.exists()) {
454 throw new ServerHttpException(404, "No such file: "
455 + audio.getLocation());
456 }
457 if (!audio.isReadable()) {
458 throw new ServerHttpException(403, "No access: "
459 + audio.getLocation());
460 }
461 return audio;
462 }
463
464 private IOUtils.HttpRequest initConnection(final InputStream socketIn,
465 final OutputStream socketOut, final Writer socketOutWriter)
466 throws IOException, ServerHttpException {
467
468 String request = IOUtils.readLine(socketIn);
469 if (Thread.currentThread().isInterrupted()) {
470 return null;
471 }
472 IOUtils.HttpRequest req = IOUtils.parseRequest(request);
473 IOUtils.readRequest(socketIn);
474
475
476 while (req.method.equals("HEAD")) {
477 final boolean isServer = checkIsServer(req.path);
478 long size = -1;
479 final String resultMime;
480 if (!isServer) {
481 checkAuth(req.query);
482 final AbstractAudio track = fileFromPath(req.path);
483 size = track.getSize();
484 resultMime = track.getMimeType();
485 } else {
486 resultMime = "text/xml; charset=UTF-8";
487 }
488 IOUtils.writeHttpResponse(req.version, 200, true, size, resultMime,
489 socketOut);
490 IOUtils.writeLine(socketOut);
491 request = IOUtils.readLine(socketIn);
492 if (Thread.currentThread().isInterrupted()) {
493 return null;
494 }
495 if (MiscUtils.isEmpty(request)) {
496 return null;
497 }
498 req = IOUtils.parseRequest(request);
499 IOUtils.readRequest(socketIn);
500 }
501 if (!req.method.equals("GET")) {
502 throw new ServerHttpException(501, "Unsupported method: "
503 + req.method);
504 }
505 final boolean isServer = checkIsServer(req.path);
506 if (isServer) {
507 handleMetadataRetrieval(req, socketOut, socketOutWriter);
508 return req;
509 }
510 checkAuth(req.query);
511 handleFileTransfer(req, socketOut);
512 return null;
513 }
514
515 private boolean checkIsServer(String path) throws ServerHttpException {
516 final boolean isServer = path.equals("/ampache/server/xml.server.php");
517 final boolean isPlay = path.startsWith("/ampache/play/");
518 if (!isServer && !isPlay) {
519 throw new ServerHttpException(404, "Unhandled URL " + path);
520 }
521 return isServer;
522 }
523
524 private void handleFileTransfer(IOUtils.HttpRequest req,
525 OutputStream socketOut) throws IOException, ServerHttpException {
526 final AbstractAudio file = fileFromPath(req.path);
527 IOUtils.writeHttpResponse(req.version, 200, false, file.getSize(), file
528 .getMimeType(), socketOut);
529 IOUtils.writeLine(socketOut);
530 IOUtils.copy(file.openInputStream(), socketOut, 8192);
531 }
532
533 private Map<String, String> handleMetadataRetrieval(
534 IOUtils.HttpRequest req, OutputStream socketOut,
535 Writer socketOutWriter) throws IOException {
536
537 IOUtils.writeHttpResponse(req.version, 200, true, -1,
538 "text/xml; charset=UTF-8", socketOut);
539 IOUtils.writeLine(
540 "Content-Disposition: attachment; filename=information.xml",
541 socketOut);
542 IOUtils.writeLine("Vary: Accept-Encoding", socketOut);
543 IOUtils.writeLine(socketOut);
544 socketOut.flush();
545 socketOutWriter.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
546 return req.query;
547 }
548
549 private void writeAmpacheError(final String errorCode, final String text,
550 Writer socketOut) throws IOException {
551 socketOut.append("<root>\n");
552 socketOut.append("\t<error code=\"" + errorCode + "\">" + text
553 + "</error>\n");
554 socketOut.append("</root>\n");
555 socketOut.close();
556 }
557
558 @Override
559 protected void onStopping() {
560 synchronized (validSessions) {
561 validSessions.clear();
562 }
563 super.onStopping();
564 }
565 }