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.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("&", "&amp;").replace("<",
379 				"&lt;").replace(">", "&gt;");
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 		// okay
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 		// read request
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 		// the 'HEAD' line should look like
475 		// HEAD /path/ HTTP/1.0
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 		// write response
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 }