com.budrotech.jukebox.service.RESTMusicService.java Source code

Java tutorial

Introduction

Here is the source code for com.budrotech.jukebox.service.RESTMusicService.java

Source

/*
 This file is part of Subsonic.
    
 Subsonic is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.
    
 Subsonic is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.
    
 You should have received a copy of the GNU General Public License
 along with Subsonic.  If not, see <http://www.gnu.org/licenses/>.
    
 Copyright 2009 (C) Sindre Mehus
 */
package com.budrotech.jukebox.service;

import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.util.Log;

import com.budrotech.jukebox.R;
import com.budrotech.jukebox.domain.Artist;
import com.budrotech.jukebox.domain.Bookmark;
import com.budrotech.jukebox.domain.ChatMessage;
import com.budrotech.jukebox.domain.Genre;
import com.budrotech.jukebox.domain.Indexes;
import com.budrotech.jukebox.domain.JukeboxStatus;
import com.budrotech.jukebox.domain.Lyrics;
import com.budrotech.jukebox.domain.MusicDirectory;
import com.budrotech.jukebox.domain.MusicFolder;
import com.budrotech.jukebox.domain.Playlist;
import com.budrotech.jukebox.domain.SearchCriteria;
import com.budrotech.jukebox.domain.SearchResult;
import com.budrotech.jukebox.domain.ServerInfo;
import com.budrotech.jukebox.domain.Share;
import com.budrotech.jukebox.domain.UserInfo;
import com.budrotech.jukebox.domain.Version;
import com.budrotech.jukebox.service.parser.AlbumListParser;
import com.budrotech.jukebox.service.parser.BookmarkParser;
import com.budrotech.jukebox.service.parser.ChatMessageParser;
import com.budrotech.jukebox.service.parser.ErrorParser;
import com.budrotech.jukebox.service.parser.GenreParser;
import com.budrotech.jukebox.service.parser.IndexesParser;
import com.budrotech.jukebox.service.parser.JukeboxStatusParser;
import com.budrotech.jukebox.service.parser.LicenseParser;
import com.budrotech.jukebox.service.parser.LyricsParser;
import com.budrotech.jukebox.service.parser.MusicDirectoryParser;
import com.budrotech.jukebox.service.parser.MusicFoldersParser;
import com.budrotech.jukebox.service.parser.PlaylistParser;
import com.budrotech.jukebox.service.parser.PlaylistsParser;
import com.budrotech.jukebox.service.parser.RandomSongsParser;
import com.budrotech.jukebox.service.parser.SearchResult2Parser;
import com.budrotech.jukebox.service.parser.SearchResultParser;
import com.budrotech.jukebox.service.parser.ShareParser;
import com.budrotech.jukebox.service.parser.UserInfoParser;
import com.budrotech.jukebox.service.ssl.SSLSocketFactory;
import com.budrotech.jukebox.service.ssl.TrustSelfSignedStrategy;
import com.budrotech.jukebox.util.CancellableTask;
import com.budrotech.jukebox.util.Constants;
import com.budrotech.jukebox.util.FileUtil;
import com.budrotech.jukebox.util.ProgressListener;
import com.budrotech.jukebox.util.Util;

import org.apache.http.conn.scheme.SocketFactory;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;

import okhttp3.HttpUrl;
import okhttp3.Response;

import static java.util.Arrays.asList;

/**
 * @author Sindre Mehus
 */
public class RESTMusicService implements MusicService {

    private static final String TAG = RESTMusicService.class.getSimpleName();

    static final int SOCKET_CONNECT_TIMEOUT = 10 * 1000;
    static final int SOCKET_READ_TIMEOUT_DEFAULT = 10 * 1000;
    private static final int SOCKET_READ_TIMEOUT_DOWNLOAD = 30 * 1000;
    private static final int SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS = 60 * 1000;
    private static final int SOCKET_READ_TIMEOUT_GET_PLAYLIST = 60 * 1000;

    // Allow 20 seconds extra timeout per MB offset.
    private static final double TIMEOUT_MILLIS_PER_OFFSET_BYTE = 20000.0 / 1000000.0;

    /**
     * URL from which to fetch latest versions.
     */
    private static final String VERSION_URL = "http://subsonic.org/backend/version.view";

    static final int HTTP_REQUEST_MAX_ATTEMPTS = 5;

    public RESTMusicService() {
        //
        //      // Create and initialize scheme registry
        //      SchemeRegistry schemeRegistry = new SchemeRegistry();
        //      schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
        //      schemeRegistry.register(new Scheme("https", createSSLSocketFactory(), 443));
        //
        //      // Create an HttpClient with the ThreadSafeClientConnManager.
        //      // This connection manager must be used if more than one thread will
        //      // be using the HttpClient.
        //      connManager = new ThreadSafeClientConnManager(params, schemeRegistry);
        //      httpClient = new DefaultHttpClient(connManager, params);
    }

    private static SocketFactory createSSLSocketFactory() {
        try {
            return new SSLSocketFactory(new TrustSelfSignedStrategy(),
                    SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
        } catch (Throwable x) {
            Log.e(TAG, "Failed to create custom SSL socket factory, using default.", x);
            return org.apache.http.conn.ssl.SSLSocketFactory.getSocketFactory();
        }
    }

    public static HttpUrl.Builder getSubsonicUrl(Context context, String method) {
        SharedPreferences preferences = Util.getPreferences(context);

        int instance = preferences.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1);
        String serverUrl = preferences.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null);
        String username = preferences.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null);
        String password = preferences.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null);

        // Slightly obfuscate password
        password = "enc:" + Util.utf8HexEncode(password);

        HttpUrl.Builder builder = HttpUrl.parse(serverUrl).newBuilder();
        builder.addPathSegment("rest");
        builder.addPathSegment(method + ".view");
        builder.addQueryParameter("u", username);
        builder.addQueryParameter("p", password);
        builder.addQueryParameter("v", Constants.REST_PROTOCOL_VERSION);
        builder.addQueryParameter("c", Constants.REST_CLIENT_ID);

        return builder;
    }

    @Override
    public void ping(Context context, ProgressListener progressListener) throws Exception {
        Reader reader = getReader(context, progressListener, "ping");
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception {
        Reader reader = getReader(context, progressListener, "getLicense");
        try {
            ServerInfo serverInfo = new LicenseParser(context).parse(reader);
            return serverInfo.isLicenseValid();
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public List<MusicFolder> getMusicFolders(boolean refresh, Context context, ProgressListener progressListener)
            throws Exception {
        List<MusicFolder> cachedMusicFolders = readCachedMusicFolders(context);
        if (cachedMusicFolders != null && !refresh) {
            return cachedMusicFolders;
        }

        Reader reader = getReader(context, progressListener, "getMusicFolders");
        try {
            List<MusicFolder> musicFolders = new MusicFoldersParser(context).parse(reader, progressListener);
            writeCachedMusicFolders(context, musicFolders);
            return musicFolders;
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public Indexes getIndexes(String musicFolderId, boolean refresh, Context context,
            ProgressListener progressListener) throws Exception {
        Indexes cachedIndexes = readCachedIndexes(context, musicFolderId);
        if (cachedIndexes != null && !refresh) {
            return cachedIndexes;
        }

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        if (musicFolderId != null) {
            parameterNames.add("musicFolderId");
            parameterValues.add(musicFolderId);
        }

        Reader reader = getReader(context, progressListener, "getIndexes", parameterNames, parameterValues);

        try {
            Indexes indexes = new IndexesParser(context).parse(reader, progressListener);
            if (indexes != null) {
                writeCachedIndexes(context, indexes, musicFolderId);
                return indexes;
            }

            return cachedIndexes != null ? cachedIndexes
                    : new Indexes(0, null, new ArrayList<Artist>(), new ArrayList<Artist>());
        } finally {
            Util.close(reader);
        }
    }

    private static Indexes readCachedIndexes(Context context, String musicFolderId) {
        String filename = getCachedIndexesFilename(context, musicFolderId);
        return FileUtil.deserialize(context, filename);
    }

    private static void writeCachedIndexes(Context context, Indexes indexes, String musicFolderId) {
        String filename = getCachedIndexesFilename(context, musicFolderId);
        FileUtil.serialize(context, indexes, filename);
    }

    private static String getCachedIndexesFilename(Context context, String musicFolderId) {
        String s = Util.getRestUrl(context, null) + musicFolderId;
        return String.format("indexes-%d.ser", Math.abs(s.hashCode()));
    }

    @Override
    public Indexes getArtists(boolean refresh, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.8", "Artists by ID3 tag not supported.");

        Indexes cachedArtists = readCachedArtists(context);
        if (cachedArtists != null && !refresh) {
            return cachedArtists;
        }

        Reader reader = getReader(context, progressListener, "getArtists");
        try {
            Indexes indexes = new IndexesParser(context).parse(reader, progressListener);
            if (indexes != null) {
                writeCachedArtists(context, indexes);
                return indexes;
            }

            return cachedArtists != null ? cachedArtists
                    : new Indexes(0, null, new ArrayList<Artist>(), new ArrayList<Artist>());
        } finally {
            Util.close(reader);
        }
    }

    private static Indexes readCachedArtists(Context context) {
        String filename = getCachedArtistsFilename(context);
        return FileUtil.deserialize(context, filename);
    }

    private static void writeCachedArtists(Context context, Indexes artists) {
        String filename = getCachedArtistsFilename(context);
        FileUtil.serialize(context, artists, filename);
    }

    private static String getCachedArtistsFilename(Context context) {
        String s = Util.getRestUrl(context, null);
        return String.format("indexes-%d.ser", Math.abs(s.hashCode()));
    }

    private static ArrayList<MusicFolder> readCachedMusicFolders(Context context) {
        String filename = getCachedMusicFoldersFilename(context);
        return FileUtil.deserialize(context, filename);
    }

    private static void writeCachedMusicFolders(Context context, List<MusicFolder> musicFolders) {
        String filename = getCachedMusicFoldersFilename(context);
        FileUtil.serialize(context, new ArrayList<MusicFolder>(musicFolders), filename);
    }

    private static String getCachedMusicFoldersFilename(Context context) {
        String s = Util.getRestUrl(context, null);
        return String.format("musicFolders-%d.ser", Math.abs(s.hashCode()));
    }

    @Override
    public void star(String id, String albumId, String artistId, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.8", "Starring not supported.");

        List<String> parameterNames = new LinkedList<String>();
        List<Object> parameterValues = new LinkedList<Object>();

        if (id != null) {
            parameterNames.add("id");
            parameterValues.add(id);
        }

        if (albumId != null) {
            parameterNames.add("albumId");
            parameterValues.add(albumId);
        }

        if (artistId != null) {
            parameterNames.add("artistId");
            parameterValues.add(artistId);
        }

        Reader reader = getReader(context, progressListener, "star", parameterNames, parameterValues);
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void unstar(String id, String albumId, String artistId, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Unstarring not supported.");

        List<String> parameterNames = new LinkedList<String>();
        List<Object> parameterValues = new LinkedList<Object>();

        if (id != null) {
            parameterNames.add("id");
            parameterValues.add(id);
        }

        if (albumId != null) {
            parameterNames.add("albumId");
            parameterValues.add(albumId);
        }

        if (artistId != null) {
            parameterNames.add("artistId");
            parameterValues.add(artistId);
        }

        Reader reader = getReader(context, progressListener, "unstar", parameterNames, parameterValues);
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public MusicDirectory getMusicDirectory(String id, String name, boolean refresh, Context context,
            ProgressListener progressListener) throws Exception {
        Reader reader = getReaderForId(context, progressListener, "getMusicDirectory", id);
        try {
            return new MusicDirectoryParser(context).parse(name, reader, progressListener, false);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public MusicDirectory getArtist(String id, String name, boolean refresh, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Artist by ID3 tag not supported.");

        Reader reader = getReaderForId(context, progressListener, "getArtist", id);
        try {
            return new MusicDirectoryParser(context).parse(name, reader, progressListener, false);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public MusicDirectory getAlbum(String id, String name, boolean refresh, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Album by ID3 tag not supported.");

        Reader reader = getReaderForId(context, progressListener, "getAlbum", id);
        try {
            return new MusicDirectoryParser(context).parse(name, reader, progressListener, true);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public SearchResult search(SearchCriteria criteria, Context context, ProgressListener progressListener)
            throws Exception {
        try {
            return !Util.isOffline(context) && Util.getShouldUseId3Tags(context)
                    ? search3(criteria, context, progressListener)
                    : search2(criteria, context, progressListener);
        } catch (ServerTooOldException x) {
            // Ensure backward compatibility with REST 1.3.
            return searchOld(criteria, context, progressListener);
        }
    }

    /**
     * Search using the "search" REST method.
     */
    private SearchResult searchOld(SearchCriteria criteria, Context context, ProgressListener progressListener)
            throws Exception {
        List<String> parameterNames = asList("any", "songCount");
        List<Object> parameterValues = Arrays.<Object>asList(criteria.getQuery(), criteria.getSongCount());
        Reader reader = getReader(context, progressListener, "search", parameterNames, parameterValues);
        try {
            return new SearchResultParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    /**
     * Search using the "search2" REST method, available in 1.4.0 and later.
     */
    private SearchResult search2(SearchCriteria criteria, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.4", "Search2 not supported.");

        List<String> parameterNames = asList("query", "artistCount", "albumCount", "songCount");
        List<Object> parameterValues = Arrays.<Object>asList(criteria.getQuery(), criteria.getArtistCount(),
                criteria.getAlbumCount(), criteria.getSongCount());
        Reader reader = getReader(context, progressListener, "search2", parameterNames, parameterValues);
        try {
            return new SearchResult2Parser(context).parse(reader, progressListener, false);
        } finally {
            Util.close(reader);
        }
    }

    private SearchResult search3(SearchCriteria criteria, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.8", "Searching by ID3 tag not supported.");

        List<String> parameterNames = asList("query", "artistCount", "albumCount", "songCount");
        List<Object> parameterValues = Arrays.<Object>asList(criteria.getQuery(), criteria.getArtistCount(),
                criteria.getAlbumCount(), criteria.getSongCount());
        Reader reader = getReader(context, progressListener, "search3", parameterNames, parameterValues);
        try {
            return new SearchResult2Parser(context).parse(reader, progressListener, true);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public MusicDirectory getPlaylist(String id, String name, Context context, ProgressListener progressListener)
            throws Exception {
        SubsonicRequest request = new SubsonicRequest(context, "getPlaylist").addQueryParameter("id", id)
                .setSocketReadTimeout(SOCKET_READ_TIMEOUT_GET_PLAYLIST);

        Reader reader = request.getResponse(progressListener, null).body().charStream();
        try {
            MusicDirectory playlist = new PlaylistParser(context).parse(reader, progressListener);

            File playlistFile = FileUtil.getPlaylistFile(Util.getServerName(context), name);
            FileWriter fw = new FileWriter(playlistFile);
            BufferedWriter bw = new BufferedWriter(fw);
            try {
                fw.write("#EXTM3U\n");
                for (MusicDirectory.Entry e : playlist.getChildren()) {
                    String filePath = FileUtil.getSongFile(context, e).getAbsolutePath();
                    if (!new File(filePath).exists()) {
                        String ext = FileUtil.getExtension(filePath);
                        String base = FileUtil.getBaseName(filePath);
                        filePath = base + ".complete." + ext;
                    }
                    fw.write(filePath + '\n');
                }
            } catch (Exception e) {
                Log.w(TAG, "Failed to save playlist: " + name);
            } finally {
                bw.close();
                fw.close();
            }

            return playlist;
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public List<Playlist> getPlaylists(boolean refresh, Context context, ProgressListener progressListener)
            throws Exception {
        Reader reader = getReader(context, progressListener, "getPlaylists");
        try {
            return new PlaylistsParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void createPlaylist(String id, String name, List<MusicDirectory.Entry> entries, Context context,
            ProgressListener progressListener) throws Exception {
        List<String> parameterNames = new LinkedList<String>();
        List<Object> parameterValues = new LinkedList<Object>();

        if (id != null) {
            parameterNames.add("playlistId");
            parameterValues.add(id);
        }
        if (name != null) {
            parameterNames.add("name");
            parameterValues.add(name);
        }
        for (MusicDirectory.Entry entry : entries) {
            parameterNames.add("songId");
            parameterValues.add(entry.getId());
        }

        Reader reader = getReader(context, progressListener, "createPlaylist", parameterNames, parameterValues);
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void deletePlaylist(String id, Context context, ProgressListener progressListener) throws Exception {
        Reader reader = getReaderForId(context, progressListener, "deletePlaylist", id);
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void updatePlaylist(String id, List<MusicDirectory.Entry> toAdd, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Updating playlist not supported.");

        List<String> names = new ArrayList<String>();
        List<Object> values = new ArrayList<Object>();
        names.add("playlistId");
        values.add(id);
        for (MusicDirectory.Entry song : toAdd) {
            names.add("songIdToAdd");
            values.add(song.getId());
        }
        Reader reader = getReader(context, progressListener, "updatePlaylist", names, values);
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void removeFromPlaylist(String id, List<Integer> toRemove, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Updating playlists is not supported.");
        List<String> names = new ArrayList<String>();
        List<Object> values = new ArrayList<Object>();
        names.add("playlistId");
        values.add(id);
        for (Integer song : toRemove) {
            names.add("songIndexToRemove");
            values.add(song);
        }
        Reader reader = getReader(context, progressListener, "updatePlaylist", names, values);
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void updatePlaylist(String id, String name, String comment, boolean pub, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Updating playlists is not supported.");
        Reader reader = getReader(context, progressListener, "updatePlaylist",
                asList("playlistId", "name", "comment", "public"), Arrays.<Object>asList(id, name, comment, pub));
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.2", "Lyrics not supported.");

        Reader reader = getReader(context, progressListener, "getLyrics", asList("artist", "title"),
                Arrays.<Object>asList(artist, title));
        try {
            return new LyricsParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.5", "Scrobbling not supported.");

        Reader reader = getReader(context, progressListener, "scrobble", asList("id", "submission"),
                Arrays.<Object>asList(id, submission));
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public MusicDirectory getAlbumList(String type, int size, int offset, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.2", "Album list not supported.");

        Reader reader = getReader(context, progressListener, "getAlbumList", asList("type", "size", "offset"),
                Arrays.<Object>asList(type, size, offset));
        try {
            return new AlbumListParser(context).parse(reader, progressListener, false);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public MusicDirectory getAlbumList2(String type, int size, int offset, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Album list by ID3 tag not supported.");

        Reader reader = getReader(context, progressListener, "getAlbumList2", asList("type", "size", "offset"),
                Arrays.<Object>asList(type, size, offset));
        try {
            return new AlbumListParser(context).parse(reader, progressListener, true);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.2", "Random songs not supported.");

        SubsonicRequest request = new SubsonicRequest(context, "getRandomSongs");
        request.setSocketReadTimeout(SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS);
        request.addQueryParameter("size", String.valueOf(size));

        Reader reader = request.getResponse(progressListener, null).body().charStream();
        try {
            return new RandomSongsParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public SearchResult getStarred(Context context, ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Starred albums not supported.");

        Reader reader = getReader(context, progressListener, "getStarred");
        try {
            return new SearchResult2Parser(context).parse(reader, progressListener, false);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public SearchResult getStarred2(Context context, ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.8", "Starred albums by ID3 tag not supported.");

        Reader reader = getReader(context, progressListener, "getStarred2");
        try {
            return new SearchResult2Parser(context).parse(reader, progressListener, true);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public Version getLocalVersion(Context context) throws Exception {
        return new Version(Util.getVersionName(context));
    }

    private static void checkServerVersion(Context context, String version, String text)
            throws ServerTooOldException {
        Version serverVersion = Util.getServerRestVersion(context);
        Version requiredVersion = new Version(version);
        boolean ok = serverVersion == null || serverVersion.compareTo(requiredVersion) >= 0;

        if (!ok) {
            throw new ServerTooOldException(text);
        }
    }

    private static boolean checkServerVersion(Context context, String version) {
        Version serverVersion = Util.getServerRestVersion(context);
        Version requiredVersion = new Version(version);
        return serverVersion == null || serverVersion.compareTo(requiredVersion) >= 0;
    }

    public static boolean isErrorResponse(Response response) {
        String contentType = response.body().contentType().toString();
        return contentType != null && contentType.startsWith("text/xml");
    }

    @Override
    public Bitmap getCoverArt(Context context, final MusicDirectory.Entry entry, int size, boolean saveToFile,
            boolean highQuality, ProgressListener progressListener) throws Exception {
        // Synchronize on the entry so that we don't download concurrently for
        // the same song.
        if (entry == null) {
            return null;
        }

        synchronized (entry) {
            // Use cached file, if existing.
            Bitmap bitmap = FileUtil.getAlbumArtBitmap(context, entry, size, highQuality);
            boolean serverScaling = Util.isServerScalingEnabled(context);

            if (bitmap == null) {
                SubsonicRequest subsonicRequest = new SubsonicRequest(context, "getCoverArt");

                InputStream in = null;
                try {
                    subsonicRequest.addQueryParameter("id", entry.getCoverArt());
                    if (serverScaling) {
                        subsonicRequest.addQueryParameter("size", String.valueOf(size));
                    }

                    Response response = subsonicRequest.getResponse(progressListener, null);
                    in = response.body().byteStream();

                    // If content type is XML, an error occurred. Get it.
                    if (isErrorResponse(response)) {
                        new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8));
                        return null; // Never reached.
                    }

                    byte[] bytes = Util.toByteArray(in);

                    // If we aren't allowing server-side scaling, always save the file to disk because it will be unmodified
                    if (!serverScaling || saveToFile) {
                        OutputStream out = null;

                        try {
                            out = new FileOutputStream(FileUtil.getAlbumArtFile(context, entry));
                            out.write(bytes);
                        } finally {
                            Util.close(out);
                        }
                    }

                    bitmap = FileUtil.getSampledBitmap(bytes, size, highQuality);
                } finally {
                    Util.close(in);
                }
            }

            // Return scaled bitmap
            return Util.scaleBitmap(bitmap, size);
        }
    }

    @Override
    public Response getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate,
            CancellableTask task) throws Exception {
        SubsonicRequest request = new SubsonicRequest(context, "stream");
        request.addQueryParameter("id", song.getId());
        request.addQueryParameter("maxBitRate", String.valueOf(maxBitrate));

        // Set socket read timeout. Note: The timeout increases as the offset gets larger. This is
        // to avoid the thrashing effect seen when offset is combined with transcoding/downsampling on the server.
        // In that case, the server uses a long time before sending any data, causing the client to time out.
        request.setSocketReadTimeout(
                (int) (SOCKET_READ_TIMEOUT_DOWNLOAD + offset * TIMEOUT_MILLIS_PER_OFFSET_BYTE));

        if (offset > 0) {
            request.addBasicHeader("Range", String.format("bytes=%d-", offset));
        }

        Response response = request.getResponse(null, task);

        // If content type is XML, an error occurred.  Get it.
        if (isErrorResponse(response)) {
            Reader in = response.body().charStream();
            try {
                new ErrorParser(context).parse(in);
            } finally {
                Util.close(in);
            }
        }

        return response;
    }

    @Override
    public JukeboxStatus updateJukeboxPlaylist(List<String> ids, Context context, ProgressListener progressListener)
            throws Exception {
        int n = ids.size();
        List<String> parameterNames = new ArrayList<String>(n + 1);
        parameterNames.add("action");

        for (String ignored : ids) {
            parameterNames.add("id");
        }

        List<Object> parameterValues = new ArrayList<Object>();
        parameterValues.add("set");
        parameterValues.addAll(ids);

        return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues);
    }

    @Override
    public JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context,
            ProgressListener progressListener) throws Exception {
        List<String> parameterNames = asList("action", "index", "offset");
        List<Object> parameterValues = Arrays.<Object>asList("skip", index, offsetSeconds);
        return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues);
    }

    @Override
    public JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception {
        return executeJukeboxCommand(context, progressListener, Collections.singletonList("action"),
                Collections.<Object>singletonList("stop"));
    }

    @Override
    public JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception {
        return executeJukeboxCommand(context, progressListener, Collections.singletonList("action"),
                Collections.<Object>singletonList("start"));
    }

    @Override
    public JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception {
        return executeJukeboxCommand(context, progressListener, Collections.singletonList("action"),
                Collections.<Object>singletonList("status"));
    }

    @Override
    public JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener)
            throws Exception {
        List<String> parameterNames = asList("action", "gain");
        List<Object> parameterValues = Arrays.<Object>asList("setGain", gain);
        return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues);
    }

    @Override
    public List<Share> getShares(boolean refresh, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.6", "Shares not supported.");
        Reader reader = getReader(context, progressListener, "getShares");
        try {
            return new ShareParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    private JukeboxStatus executeJukeboxCommand(Context context, ProgressListener progressListener,
            List<String> parameterNames, List<Object> parameterValues) throws Exception {
        checkServerVersion(context, "1.7", "Jukebox not supported.");
        Reader reader = getReader(context, progressListener, "jukeboxControl", parameterNames, parameterValues);
        try {
            return new JukeboxStatusParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    private Reader getReader(Context context, ProgressListener progressListener, String method) throws Exception {
        SubsonicRequest request = new SubsonicRequest(context, method);
        return request.getResponse(progressListener, null).body().charStream();
    }

    private Reader getReaderForId(Context context, ProgressListener progressListener, String method, String id)
            throws Exception {
        SubsonicRequest request = new SubsonicRequest(context, method);
        request.addQueryParameter("id", id);
        return request.getResponse(progressListener, null).body().charStream();
    }

    private Reader getReader(Context context, ProgressListener progressListener, String method,
            List<String> parameterNames, List<Object> parameterValues) throws Exception {

        if (progressListener != null) {
            progressListener.updateProgress(R.string.service_connecting);
        }

        SubsonicRequest request = new SubsonicRequest(context, method);

        // If not too many parameters, extract them to the URL rather than
        // relying on the HTTP POST request being
        // received intact. Remember, HTTP POST requests are converted to GET
        // requests during HTTP redirects, thus
        // loosing its entity.

        if (parameterNames != null) {
            for (int i = 0; i < parameterNames.size(); i++) {
                request.addQueryParameter(parameterNames.get(i), String.valueOf(parameterValues.get(i)));
            }
        }

        return request.getResponse(progressListener, null).body().charStream();
    }

    @Override
    public List<Genre> getGenres(Context context, ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.9", "Genres not supported.");

        Reader reader = getReader(context, progressListener, "getGenres");
        try {
            return new GenreParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public MusicDirectory getSongsByGenre(String genre, int count, int offset, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.9", "Genres not supported.");

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        parameterNames.add("genre");
        parameterValues.add(genre);
        parameterNames.add("count");
        parameterValues.add(count);
        parameterNames.add("offset");
        parameterValues.add(offset);

        Reader reader = getReader(context, progressListener, "getSongsByGenre", parameterNames, parameterValues);

        try {
            return new RandomSongsParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public UserInfo getUser(String username, Context context, ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.3", "getUser not supported.");

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        parameterNames.add("username");
        parameterValues.add(username);

        Reader reader = getReader(context, progressListener, "getUser", parameterNames, parameterValues);

        try {
            return new UserInfoParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public List<ChatMessage> getChatMessages(Long since, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.2", "Chat not supported.");

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        parameterNames.add("since");
        parameterValues.add(since);

        Reader reader = getReader(context, progressListener, "getChatMessages", parameterNames, parameterValues);

        try {
            return new ChatMessageParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void addChatMessage(String message, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.2", "Chat not supported.");

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        parameterNames.add("message");
        parameterValues.add(message);

        Reader reader = getReader(context, progressListener, "addChatMessage", parameterNames, parameterValues);

        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public List<Bookmark> getBookmarks(Context context, ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.9", "Bookmarks not supported.");

        Reader reader = getReader(context, progressListener, "getBookmarks");

        try {
            return new BookmarkParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void createBookmark(String id, int position, Context context, ProgressListener progressListener)
            throws Exception {
        checkServerVersion(context, "1.9", "Bookmarks not supported.");

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        parameterNames.add("id");
        parameterValues.add(id);
        parameterNames.add("position");
        parameterValues.add(position);

        Reader reader = getReader(context, progressListener, "createBookmark", parameterNames, parameterValues);

        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void deleteBookmark(String id, Context context, ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.9", "Bookmarks not supported.");

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        parameterNames.add("id");
        parameterValues.add(id);

        Reader reader = getReader(context, progressListener, "deleteBookmark", parameterNames, parameterValues);

        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public List<Share> createShare(List<String> ids, String description, Long expires, Context context,
            ProgressListener progressListener) throws Exception {
        List<String> parameterNames = new LinkedList<String>();
        List<Object> parameterValues = new LinkedList<Object>();

        for (String id : ids) {
            parameterNames.add("id");
            parameterValues.add(id);
        }

        if (description != null) {
            parameterNames.add("description");
            parameterValues.add(description);
        }

        if (expires > 0) {
            parameterNames.add("expires");
            parameterValues.add(expires);
        }

        Reader reader = getReader(context, progressListener, "createShare", parameterNames, parameterValues);
        try {
            return new ShareParser(context).parse(reader, progressListener);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void deleteShare(String id, Context context, ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.6", "Shares not supported.");

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        parameterNames.add("id");
        parameterValues.add(id);

        Reader reader = getReader(context, progressListener, "deleteShare", parameterNames, parameterValues);

        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public void updateShare(String id, String description, Long expires, Context context,
            ProgressListener progressListener) throws Exception {
        checkServerVersion(context, "1.6", "Updating share not supported.");

        List<String> parameterNames = new ArrayList<String>();
        List<Object> parameterValues = new ArrayList<Object>();

        parameterNames.add("id");
        parameterValues.add(id);

        if (description != null) {
            parameterNames.add("description");
            parameterValues.add(description);
        }

        if (expires > 0) {
            parameterNames.add("expires");
            parameterValues.add(expires);
        }

        Reader reader = getReader(context, progressListener, "updateShare", parameterNames, parameterValues);
        try {
            new ErrorParser(context).parse(reader);
        } finally {
            Util.close(reader);
        }
    }

    @Override
    public Bitmap getAvatar(Context context, String username, int size, boolean saveToFile, boolean highQuality,
            ProgressListener progressListener) throws Exception {
        // Return silently if server is too old
        if (!checkServerVersion(context, "1.8"))
            return null;

        // Synchronize on the username so that we don't download concurrently for
        // the same user.
        if (username == null) {
            return null;
        }

        synchronized (username) {
            // Use cached file, if existing.
            Bitmap bitmap = FileUtil.getAvatarBitmap(username, size, highQuality);

            if (bitmap == null) {
                InputStream in = null;

                try {
                    SubsonicRequest request = new SubsonicRequest(context, "getAvatar");
                    request.addQueryParameter("username", username);
                    Response response = request.getResponse(progressListener, null);
                    in = response.body().byteStream();

                    // If content type is XML, an error occurred. Get it.
                    if (isErrorResponse(response)) {
                        new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8));
                        return null; // Never reached.
                    }

                    byte[] bytes = Util.toByteArray(in);

                    // If we aren't allowing server-side scaling, always save the file to disk because it will be unmodified
                    if (saveToFile) {
                        OutputStream out = null;

                        try {
                            out = new FileOutputStream(FileUtil.getAvatarFile(username));
                            out.write(bytes);
                        } finally {
                            Util.close(out);
                        }
                    }

                    bitmap = FileUtil.getSampledBitmap(bytes, size, highQuality);
                } finally {
                    Util.close(in);
                }
            }

            // Return scaled bitmap
            return Util.scaleBitmap(bitmap, size);
        }
    }
}