diff --git a/dependencies.gradle b/dependencies.gradle index af48b1d2..c1a4f788 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -18,7 +18,7 @@ ext.versions = [ retrofit : "2.1.0", jackson : "2.9.0", - okhttp : "3.6.0", + okhttp : "3.9.0", junit : "4.12", mockitoKotlin : "1.5.0", diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/CommonFunctions.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/CommonFunctions.kt index 5c052f7f..5efa0bf3 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/CommonFunctions.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/CommonFunctions.kt @@ -46,7 +46,7 @@ fun parseDate(dateAsString: String): Calendar { return result } -fun checkErrorCallParsed(mockWebServerRule: MockWebServerRule, +fun checkErrorCallParsed(mockWebServerRule : MockWebServerRule, apiRequest: () -> Response): T { mockWebServerRule.enqueueResponse("generic_error_response.json") diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiGetCoverArtTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiGetCoverArtTest.kt index d0c6d647..3b22d396 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiGetCoverArtTest.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiGetCoverArtTest.kt @@ -19,7 +19,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() { with(response) { stream `should be` null - requestErrorCode `should be` null + responseHttpCode `should equal to` 200 apiError `should equal` SubsonicError.GENERIC } } @@ -33,7 +33,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() { with(response) { stream `should be` null - requestErrorCode `should equal` 404 + responseHttpCode `should equal` 404 apiError `should be` null } } @@ -46,7 +46,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() { val response = client.getCoverArt("some-id") with(response) { - requestErrorCode `should be` null + responseHttpCode `should equal to` 200 apiError `should be` null stream `should not be` null val expectedContent = mockWebServerRule.loadJsonResponse("ping_ok.json") diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiStreamTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiStreamTest.kt new file mode 100644 index 00000000..b0255d66 --- /dev/null +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiStreamTest.kt @@ -0,0 +1,125 @@ +package org.moire.ultrasonic.api.subsonic + +import okhttp3.mockwebserver.MockResponse +import org.amshove.kluent.`should be` +import org.amshove.kluent.`should equal to` +import org.amshove.kluent.`should equal` +import org.amshove.kluent.`should not be` +import org.junit.Test + +/** + * Integration test for [SubsonicAPIClient] for [SubsonicAPIDefinition.stream] call. + */ +class SubsonicApiStreamTest : SubsonicAPIClientTest() { + @Test + fun `Should handle api error response`() { + mockWebServerRule.enqueueResponse("generic_error_response.json") + + val response = client.stream("some-id") + + with(response) { + stream `should be` null + responseHttpCode `should equal to` 200 + apiError `should equal` SubsonicError.GENERIC + } + } + + @Test + fun `Should handle server error`() { + val httpErrorCode = 404 + mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(httpErrorCode)) + + val response = client.stream("some-id") + + with(response) { + stream `should be` null + responseHttpCode `should equal to` httpErrorCode + apiError `should be` null + } + } + + @Test + fun `Should return successfull call stream`() { + mockWebServerRule.mockWebServer.enqueue(MockResponse() + .setBody(mockWebServerRule.loadJsonResponse("ping_ok.json"))) + + val response = client.stream("some-id") + + with(response) { + responseHttpCode `should equal to` 200 + apiError `should be` null + stream `should not be` null + val expectedContent = mockWebServerRule.loadJsonResponse("ping_ok.json") + stream!!.bufferedReader().readText() `should equal to` expectedContent + } + } + + @Test + fun `Should pass id as parameter`() { + val id = "asdo123" + + mockWebServerRule.assertRequestParam("ping_ok.json", id) { + client.api.stream(id = id).execute() + } + } + + @Test + fun `Should pass max bit rate as param`() { + val maxBitRate = 360 + + mockWebServerRule.assertRequestParam("ping_ok.json", + "maxBitRate=$maxBitRate") { + client.api.stream("some-id", maxBitRate = maxBitRate).execute() + } + } + + @Test + fun `Should pass format as param`() { + val format = "aac" + + mockWebServerRule.assertRequestParam("ping_ok.json", + "format=$format") { + client.api.stream("some-id", format = format).execute() + } + } + + @Test + fun `Should pass time offset as param`() { + val timeOffset = 155 + + mockWebServerRule.assertRequestParam("ping_ok.json", + "timeOffset=$timeOffset") { + client.api.stream("some-id", timeOffset = timeOffset).execute() + } + } + + @Test + fun `Should pass video size as param`() { + val videoSize = "44144" + + mockWebServerRule.assertRequestParam("ping_ok.json", + "size=$videoSize") { + client.api.stream("some-id", videoSize = videoSize).execute() + } + } + + @Test + fun `Should pass estimate content length as param`() { + val estimateContentLength = true + + mockWebServerRule.assertRequestParam("ping_ok.json", + "estimateContentLength=$estimateContentLength") { + client.api.stream("some-id", estimateContentLength = estimateContentLength).execute() + } + } + + @Test + fun `Should pass converted as param`() { + val converted = false + + mockWebServerRule.assertRequestParam("ping_ok.json", + "converted=$converted") { + client.api.stream("some-id", converted = converted).execute() + } + } +} diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/BaseInterceptorTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/BaseInterceptorTest.kt new file mode 100644 index 00000000..c30e9227 --- /dev/null +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/BaseInterceptorTest.kt @@ -0,0 +1,35 @@ +package org.moire.ultrasonic.api.subsonic.interceptors + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.Before +import org.junit.Rule +import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule + +/** + * Base class for testing [okhttp3.Interceptor] implementations. + */ +abstract class BaseInterceptorTest { + @Rule @JvmField val mockWebServerRule = MockWebServerRule() + + lateinit var client: OkHttpClient + + abstract val interceptor: Interceptor + + @Before + fun setUp() { + client = OkHttpClient.Builder().addInterceptor(interceptor).build() + } + + /** + * Creates [Request] to use with [mockWebServerRule]. + * + * @param additionalParams passes [Request.Builder] to add additionally required + * params to the [Request]. + */ + fun createRequest(additionalParams: (Request.Builder) -> Unit): Request = Request.Builder() + .url(mockWebServerRule.mockWebServer.url("/")) + .also { additionalParams(it) } + .build() +} diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/RangeHeaderInterceptorTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/RangeHeaderInterceptorTest.kt new file mode 100644 index 00000000..e458fc0d --- /dev/null +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/RangeHeaderInterceptorTest.kt @@ -0,0 +1,59 @@ +package org.moire.ultrasonic.api.subsonic.interceptors + +import okhttp3.Interceptor +import okhttp3.mockwebserver.MockResponse +import org.amshove.kluent.`should contain` +import org.amshove.kluent.`should equal to` +import org.amshove.kluent.`should not contain` +import org.junit.Test + +/** + * Unit test for [RangeHeaderInterceptor]. + */ +class RangeHeaderInterceptorTest : BaseInterceptorTest() { + + override val interceptor: Interceptor + get() = RangeHeaderInterceptor() + + @Test + fun `Should update uppercase range header`() { + mockWebServerRule.mockWebServer.enqueue(MockResponse()) + val offset = 111 + val request = createRequest { + it.addHeader("Range", "$offset") + } + + client.newCall(request).execute() + + val executedRequest = mockWebServerRule.mockWebServer.takeRequest() + executedRequest.headers.names() `should contain` "Range" + executedRequest.headers["Range"]!! `should equal to` "bytes=$offset-" + } + + @Test + fun `Should not add range header if request doesnt contain it`() { + mockWebServerRule.mockWebServer.enqueue(MockResponse()) + val request = createRequest { } + + client.newCall(request).execute() + + val executedRequest = mockWebServerRule.mockWebServer.takeRequest() + executedRequest.headers.names() `should not contain` "Range" + executedRequest.headers.names() `should not contain` "range" + } + + @Test + fun `Should update lowercase range header`() { + mockWebServerRule.mockWebServer.enqueue(MockResponse()) + val offset = 51233 + val request = createRequest { + it.addHeader("range", "$offset") + } + + client.newCall(request).execute() + + val executedRequest = mockWebServerRule.mockWebServer.takeRequest() + executedRequest.headers.names() `should contain` "Range" + executedRequest.headers["Range"]!! `should equal to` "bytes=$offset-" + } +} diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt index 11fc9041..cd407947 100644 --- a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt @@ -8,6 +8,7 @@ import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.ResponseBody import okhttp3.logging.HttpLoggingInterceptor +import org.moire.ultrasonic.api.subsonic.interceptors.RangeHeaderInterceptor import org.moire.ultrasonic.api.subsonic.response.StreamResponse import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse import retrofit2.Response @@ -51,6 +52,7 @@ class SubsonicAPIClient(baseUrl: String, .build() chain.proceed(originalRequest.newBuilder().url(newUrl).build()) } + .addInterceptor(RangeHeaderInterceptor()) .also { if (debug) { it.addLogging() @@ -81,6 +83,20 @@ class SubsonicAPIClient(baseUrl: String, api.getCoverArt(id, size).execute() } + /** + * Convenient method to get media stream from api using item [id] and optional [maxBitrate]. + * + * Optionally also you can provide [offset] that stream should start from. + * + * It detects the response `Content-Type` and tries to parse subsonic error if there is one. + * + * Prefer this method over [SubsonicAPIDefinition.stream] as this handles error cases. + */ + fun stream(id: String, maxBitrate: Int? = null, offset: Long? = null): StreamResponse = + handleStreamResponse { + api.stream(id, maxBitrate, offset = offset).execute() + } + private inline fun handleStreamResponse(apiCall: () -> Response): StreamResponse { val response = apiCall() return if (response.isSuccessful) { @@ -90,12 +106,13 @@ class SubsonicAPIClient(baseUrl: String, contentType.type().equals("application", true) && contentType.subtype().equals("json", true)) { val error = jacksonMapper.readValue(responseBody.byteStream()) - StreamResponse(apiError = error.error) + StreamResponse(apiError = error.error, responseHttpCode = response.code()) } else { - StreamResponse(stream = responseBody.byteStream()) + StreamResponse(stream = responseBody.byteStream(), + responseHttpCode = response.code()) } } else { - StreamResponse(requestErrorCode = response.code()) + StreamResponse(responseHttpCode = response.code()) } } diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIDefinition.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIDefinition.kt index 35413f1a..d5a778e1 100644 --- a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIDefinition.kt +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIDefinition.kt @@ -24,6 +24,7 @@ import org.moire.ultrasonic.api.subsonic.response.SearchTwoResponse import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse import retrofit2.Call import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.Query import retrofit2.http.Streaming @@ -167,4 +168,15 @@ interface SubsonicAPIDefinition { @GET("getCoverArt.view") fun getCoverArt(@Query("id") id: String, @Query("size") size: Long? = null): Call + + @Streaming + @GET("stream.view") + fun stream(@Query("id") id: String, + @Query("maxBitRate") maxBitRate: Int? = null, + @Query("format") format: String? = null, + @Query("timeOffset") timeOffset: Int? = null, + @Query("size") videoSize: String? = null, + @Query("estimateContentLength") estimateContentLength: Boolean? = null, + @Query("converted") converted: Boolean? = null, + @Header("Range") offset: Long? = null): Call } diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/RangeHeaderInterceptor.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/RangeHeaderInterceptor.kt new file mode 100644 index 00000000..cbb5d031 --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/RangeHeaderInterceptor.kt @@ -0,0 +1,41 @@ +package org.moire.ultrasonic.api.subsonic.interceptors + +import okhttp3.Interceptor +import okhttp3.Interceptor.Chain +import okhttp3.Response +import java.util.concurrent.TimeUnit.MILLISECONDS + +internal const val SOCKET_READ_TIMEOUT_DOWNLOAD = 30 * 1000 +// Allow 20 seconds extra timeout pear MB offset. +internal const val TIMEOUT_MILLIS_PER_OFFSET_BYTE = 0.02 + +/** + * Modifies request "Range" header to be according to HTTP standard. + * + * Also increases read timeout to allow server to transcode response and offset it. + * + * See [range rfc](https://tools.ietf.org/html/rfc7233). + */ +internal class RangeHeaderInterceptor : Interceptor { + override fun intercept(chain: Chain): Response { + val originalRequest = chain.request() + val headers = originalRequest.headers() + return if (headers.names().contains("Range")) { + val offsetValue = headers["Range"] ?: "0" + val offset = "bytes=$offsetValue-" + chain.withReadTimeout(getReadTimeout(offsetValue.toInt()), MILLISECONDS) + .proceed(originalRequest.newBuilder() + .removeHeader("Range").addHeader("Range", offset) + .build()) + } else { + chain.proceed(originalRequest) + } + } + + // 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. + private fun getReadTimeout(offset: Int) + = (SOCKET_READ_TIMEOUT_DOWNLOAD + offset * TIMEOUT_MILLIS_PER_OFFSET_BYTE).toInt() +} diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/StreamResponse.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/StreamResponse.kt index 47292296..7af5547b 100644 --- a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/StreamResponse.kt +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/response/StreamResponse.kt @@ -5,15 +5,15 @@ import java.io.InputStream /** * Special response that contains either [stream] of data from api, or [apiError], - * or [requestErrorCode]. + * or [responseHttpCode]. * - * [requestErrorCode] will be only if there problem on http level. + * [responseHttpCode] will be there always. */ class StreamResponse(val stream: InputStream? = null, val apiError: SubsonicError? = null, - val requestErrorCode: Int? = null) { + val responseHttpCode: Int) { /** * Check if this response has error. */ - fun hasError(): Boolean = apiError != null || requestErrorCode != null + fun hasError(): Boolean = apiError != null || responseHttpCode !in 200..300 } diff --git a/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/response/StreamResponseTest.kt b/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/response/StreamResponseTest.kt index e8a2c694..76085241 100644 --- a/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/response/StreamResponseTest.kt +++ b/subsonic-api/src/test/kotlin/org/moire/ultrasonic/api/subsonic/response/StreamResponseTest.kt @@ -10,16 +10,26 @@ import org.moire.ultrasonic.api.subsonic.SubsonicError.GENERIC class StreamResponseTest { @Test fun `Should have error if subsonic error is not null`() { - StreamResponse(apiError = GENERIC).hasError() `should equal to` true + StreamResponse(apiError = GENERIC, responseHttpCode = 200).hasError() `should equal to` true } @Test - fun `Should have error if http error is not null`() { - StreamResponse(requestErrorCode = 500).hasError() `should equal to` true + fun `Should have error if http error is greater then 300`() { + StreamResponse(responseHttpCode = 301).hasError() `should equal to` true } @Test - fun `Should not have error if subsonic error and http error is null`() { - StreamResponse().hasError() `should equal to` false + fun `Should have error of http error code is lower then 200`() { + StreamResponse(responseHttpCode = 199).hasError() `should equal to` true + } + + @Test + fun `Should not have error if http code is 200`() { + StreamResponse(responseHttpCode = 200).hasError() `should equal to` false + } + + @Test + fun `Should not have error if http code is 300`() { + StreamResponse(responseHttpCode = 300).hasError() `should equal to` false } } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/CachedMusicService.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/CachedMusicService.java index 8fe09e59..38c27e44 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/CachedMusicService.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/CachedMusicService.java @@ -21,7 +21,6 @@ package org.moire.ultrasonic.service; import android.content.Context; import android.graphics.Bitmap; -import org.apache.http.HttpResponse; import org.moire.ultrasonic.domain.Bookmark; import org.moire.ultrasonic.domain.ChatMessage; import org.moire.ultrasonic.domain.Genre; @@ -43,11 +42,14 @@ import org.moire.ultrasonic.util.ProgressListener; import org.moire.ultrasonic.util.TimeLimitedCache; import org.moire.ultrasonic.util.Util; +import java.io.InputStream; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.concurrent.TimeUnit; +import kotlin.Pair; + /** * @author Sindre Mehus */ @@ -314,7 +316,7 @@ public class CachedMusicService implements MusicService } @Override - public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception + public Pair getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception { return musicService.getDownloadInputStream(context, song, offset, maxBitrate, task); } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadFile.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadFile.java index 69a138d8..9364fb2f 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadFile.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadFile.java @@ -29,10 +29,6 @@ import org.moire.ultrasonic.util.CancellableTask; import org.moire.ultrasonic.util.FileUtil; import org.moire.ultrasonic.util.Util; -import org.apache.http.Header; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; - import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -40,6 +36,8 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.RandomAccessFile; +import kotlin.Pair; + import static android.content.Context.POWER_SERVICE; import static android.os.PowerManager.ON_AFTER_RELEASE; import static android.os.PowerManager.SCREEN_DIM_WAKE_LOCK; @@ -66,7 +64,6 @@ public class DownloadFile private volatile boolean isPlaying; private volatile boolean saveWhenDone; private volatile boolean completeWhenDone; - private Integer contentLength; public DownloadFile(Context context, MusicDirectory.Entry song, boolean save) { @@ -105,11 +102,6 @@ public class DownloadFile return song.getBitRate() == null ? 160 : song.getBitRate(); } - public Integer getContentLength() - { - return contentLength; - } - public synchronized void download() { FileUtil.createDirectoryForParent(saveFile); @@ -369,30 +361,17 @@ public class DownloadFile if (compare) { // Attempt partial HTTP GET, appending to the file if it exists. - HttpResponse response = musicService.getDownloadInputStream(context, song, partialFile.length(), bitRate, DownloadTask.this); - Header contentLengthHeader = response.getFirstHeader("Content-Length"); + Pair response = musicService + .getDownloadInputStream(context, song, partialFile.length(), bitRate, + DownloadTask.this); - if (contentLengthHeader != null) - { - String contentLengthString = contentLengthHeader.getValue(); - - if (contentLengthString != null) - { - Log.i(TAG, "Content Length: " + contentLengthString); - contentLength = Integer.parseInt(contentLengthString); - } - } - - in = response.getEntity().getContent(); - boolean partial = response.getStatusLine().getStatusCode() == HttpStatus.SC_PARTIAL_CONTENT; - - if (partial) + if (response.getSecond()) { Log.i(TAG, String.format("Executed partial HTTP GET, skipping %d bytes", partialFile.length())); } - out = new FileOutputStream(partialFile, partial); - long n = copy(in, out); + out = new FileOutputStream(partialFile, response.getSecond()); + long n = copy(response.getFirst(), out); Log.i(TAG, String.format("Downloaded %d bytes to %s", n, partialFile)); out.flush(); out.close(); diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicService.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicService.java index 31ae5bac..19eb315c 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicService.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MusicService.java @@ -39,8 +39,11 @@ import org.moire.ultrasonic.domain.UserInfo; import org.moire.ultrasonic.util.CancellableTask; import org.moire.ultrasonic.util.ProgressListener; +import java.io.InputStream; import java.util.List; +import kotlin.Pair; + /** * @author Sindre Mehus */ @@ -101,7 +104,11 @@ public interface MusicService Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, boolean highQuality, ProgressListener progressListener) throws Exception; - HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception; + /** + * Return response {@link InputStream} and a {@link Boolean} that indicates if this response is + * partial. + */ + Pair getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception; String getVideoUrl(Context context, String id, boolean useFlash) throws Exception; diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/RESTMusicService.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/RESTMusicService.java index a24b1cc9..7de681be 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/RESTMusicService.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/RESTMusicService.java @@ -46,7 +46,6 @@ import org.apache.http.conn.scheme.SchemeRegistry; import org.apache.http.conn.scheme.SocketFactory; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; -import org.apache.http.message.BasicHeader; import org.apache.http.message.BasicNameValuePair; import org.apache.http.params.BasicHttpParams; import org.apache.http.params.HttpConnectionParams; @@ -133,13 +132,13 @@ import java.io.Reader; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.concurrent.atomic.AtomicReference; +import kotlin.Pair; import retrofit2.Response; import static java.util.Arrays.asList; @@ -154,12 +153,8 @@ public class RESTMusicService implements MusicService private static final int SOCKET_CONNECT_TIMEOUT = 10 * 1000; private 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; + private static final int SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS = 60 * 1000; /** * URL from which to fetch latest versions. @@ -785,14 +780,7 @@ public class RESTMusicService implements MusicService } StreamResponse response = subsonicAPIClient.getCoverArt(id, (long) size); - if (response.hasError() || response.getStream() == null) { - if (response.getApiError() != null) { - throw new SubsonicRESTException(response.getApiError().getCode(), "rest error"); - } else { - throw new IOException("Failed to make endpoint request, code: " + - response.getRequestErrorCode()); - } - } + checkStreamResponseError(response); if (response.getStream() == null) { return null; // Failed to load @@ -826,48 +814,39 @@ public class RESTMusicService implements MusicService } } - @Override - public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception - { + private void checkStreamResponseError(StreamResponse response) + throws SubsonicRESTException, IOException { + if (response.hasError() || response.getStream() == null) { + if (response.getApiError() != null) { + throw new SubsonicRESTException(response.getApiError().getCode(), "rest error"); + } else { + throw new IOException("Failed to make endpoint request, code: " + + response.getResponseHttpCode()); + } + } + } - String url = Util.getRestUrl(context, "stream"); + @Override + public Pair getDownloadInputStream(final Context context, + final MusicDirectory.Entry song, + final long offset, + final int maxBitrate, + final CancellableTask task) + throws Exception { + if (song == null) { + throw new IllegalArgumentException("Song for download is null!"); + } + long songOffset = offset < 0 ? 0 : offset; - // 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. - HttpParams params = new BasicHttpParams(); - int timeout = (int) (SOCKET_READ_TIMEOUT_DOWNLOAD + offset * TIMEOUT_MILLIS_PER_OFFSET_BYTE); - HttpConnectionParams.setSoTimeout(params, timeout); + StreamResponse response = subsonicAPIClient.stream(song.getId(), maxBitrate, songOffset); + checkStreamResponseError(response); + if (response.getStream() == null) { + throw new IOException("Null stream response"); + } + Boolean partial = response.getResponseHttpCode() == 206; - // Add "Range" header if offset is given. - Collection
headers = new ArrayList
(); - - if (offset > 0) - { - headers.add(new BasicHeader("Range", String.format("bytes=%d-", offset))); - } - - List parameterNames = asList("id", "maxBitRate"); - List parameterValues = Arrays.asList(song.getId(), maxBitrate); - HttpResponse response = getResponseForURL(context, url, params, parameterNames, parameterValues, headers, null, task); - - // If content type is XML, an error occurred. Get it. - String contentType = Util.getContentType(response.getEntity()); - if (contentType != null && contentType.startsWith("text/xml")) - { - InputStream in = response.getEntity().getContent(); - try - { - new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8)); - } - finally - { - Util.close(in); - } - } - - return response; - } + return new Pair<>(response.getStream(), partial); + } @Override public String getVideoUrl(Context context, String id, boolean useFlash) throws Exception @@ -980,11 +959,6 @@ public class RESTMusicService implements MusicService return getReader(context, progressListener, method, requestParams, Collections.emptyList(), Collections.emptyList()); } - private Reader getReader(Context context, ProgressListener progressListener, String method, HttpParams requestParams, String parameterName, Object parameterValue) throws Exception - { - return getReader(context, progressListener, method, requestParams, Collections.singletonList(parameterName), Collections.singletonList(parameterValue)); - } - private Reader getReader(Context context, ProgressListener progressListener, String method, HttpParams requestParams, List parameterNames, List parameterValues) throws Exception {