Merge pull request #57 from ultrasonic/add-get-download-stream
Add get download stream
This commit is contained in:
commit
84e59a3581
|
@ -18,7 +18,7 @@ ext.versions = [
|
||||||
|
|
||||||
retrofit : "2.1.0",
|
retrofit : "2.1.0",
|
||||||
jackson : "2.9.0",
|
jackson : "2.9.0",
|
||||||
okhttp : "3.6.0",
|
okhttp : "3.9.0",
|
||||||
|
|
||||||
junit : "4.12",
|
junit : "4.12",
|
||||||
mockitoKotlin : "1.5.0",
|
mockitoKotlin : "1.5.0",
|
||||||
|
|
|
@ -19,7 +19,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() {
|
||||||
|
|
||||||
with(response) {
|
with(response) {
|
||||||
stream `should be` null
|
stream `should be` null
|
||||||
requestErrorCode `should be` null
|
responseHttpCode `should equal to` 200
|
||||||
apiError `should equal` SubsonicError.GENERIC
|
apiError `should equal` SubsonicError.GENERIC
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() {
|
||||||
|
|
||||||
with(response) {
|
with(response) {
|
||||||
stream `should be` null
|
stream `should be` null
|
||||||
requestErrorCode `should equal` 404
|
responseHttpCode `should equal` 404
|
||||||
apiError `should be` null
|
apiError `should be` null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() {
|
||||||
val response = client.getCoverArt("some-id")
|
val response = client.getCoverArt("some-id")
|
||||||
|
|
||||||
with(response) {
|
with(response) {
|
||||||
requestErrorCode `should be` null
|
responseHttpCode `should equal to` 200
|
||||||
apiError `should be` null
|
apiError `should be` null
|
||||||
stream `should not be` null
|
stream `should not be` null
|
||||||
val expectedContent = mockWebServerRule.loadJsonResponse("ping_ok.json")
|
val expectedContent = mockWebServerRule.loadJsonResponse("ping_ok.json")
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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-"
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
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.StreamResponse
|
||||||
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
|
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
|
@ -51,6 +52,7 @@ class SubsonicAPIClient(baseUrl: String,
|
||||||
.build()
|
.build()
|
||||||
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||||
}
|
}
|
||||||
|
.addInterceptor(RangeHeaderInterceptor())
|
||||||
.also {
|
.also {
|
||||||
if (debug) {
|
if (debug) {
|
||||||
it.addLogging()
|
it.addLogging()
|
||||||
|
@ -81,6 +83,20 @@ class SubsonicAPIClient(baseUrl: String,
|
||||||
api.getCoverArt(id, size).execute()
|
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<ResponseBody>): StreamResponse {
|
private inline fun handleStreamResponse(apiCall: () -> Response<ResponseBody>): StreamResponse {
|
||||||
val response = apiCall()
|
val response = apiCall()
|
||||||
return if (response.isSuccessful) {
|
return if (response.isSuccessful) {
|
||||||
|
@ -90,12 +106,13 @@ class SubsonicAPIClient(baseUrl: String,
|
||||||
contentType.type().equals("application", true) &&
|
contentType.type().equals("application", true) &&
|
||||||
contentType.subtype().equals("json", true)) {
|
contentType.subtype().equals("json", true)) {
|
||||||
val error = jacksonMapper.readValue<SubsonicResponse>(responseBody.byteStream())
|
val error = jacksonMapper.readValue<SubsonicResponse>(responseBody.byteStream())
|
||||||
StreamResponse(apiError = error.error)
|
StreamResponse(apiError = error.error, responseHttpCode = response.code())
|
||||||
} else {
|
} else {
|
||||||
StreamResponse(stream = responseBody.byteStream())
|
StreamResponse(stream = responseBody.byteStream(),
|
||||||
|
responseHttpCode = response.code())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
StreamResponse(requestErrorCode = response.code())
|
StreamResponse(responseHttpCode = response.code())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ import org.moire.ultrasonic.api.subsonic.response.SearchTwoResponse
|
||||||
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
|
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Header
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
|
|
||||||
|
@ -167,4 +168,15 @@ interface SubsonicAPIDefinition {
|
||||||
@GET("getCoverArt.view")
|
@GET("getCoverArt.view")
|
||||||
fun getCoverArt(@Query("id") id: String,
|
fun getCoverArt(@Query("id") id: String,
|
||||||
@Query("size") size: Long? = null): Call<ResponseBody>
|
@Query("size") size: Long? = null): Call<ResponseBody>
|
||||||
|
|
||||||
|
@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<ResponseBody>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -5,15 +5,15 @@ import java.io.InputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Special response that contains either [stream] of data from api, or [apiError],
|
* 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,
|
class StreamResponse(val stream: InputStream? = null,
|
||||||
val apiError: SubsonicError? = null,
|
val apiError: SubsonicError? = null,
|
||||||
val requestErrorCode: Int? = null) {
|
val responseHttpCode: Int) {
|
||||||
/**
|
/**
|
||||||
* Check if this response has error.
|
* Check if this response has error.
|
||||||
*/
|
*/
|
||||||
fun hasError(): Boolean = apiError != null || requestErrorCode != null
|
fun hasError(): Boolean = apiError != null || responseHttpCode !in 200..300
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,16 +10,26 @@ import org.moire.ultrasonic.api.subsonic.SubsonicError.GENERIC
|
||||||
class StreamResponseTest {
|
class StreamResponseTest {
|
||||||
@Test
|
@Test
|
||||||
fun `Should have error if subsonic error is not null`() {
|
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
|
@Test
|
||||||
fun `Should have error if http error is not null`() {
|
fun `Should have error if http error is greater then 300`() {
|
||||||
StreamResponse(requestErrorCode = 500).hasError() `should equal to` true
|
StreamResponse(responseHttpCode = 301).hasError() `should equal to` true
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Should not have error if subsonic error and http error is null`() {
|
fun `Should have error of http error code is lower then 200`() {
|
||||||
StreamResponse().hasError() `should equal to` false
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,6 @@ package org.moire.ultrasonic.service;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
import org.apache.http.HttpResponse;
|
|
||||||
import org.moire.ultrasonic.domain.Bookmark;
|
import org.moire.ultrasonic.domain.Bookmark;
|
||||||
import org.moire.ultrasonic.domain.ChatMessage;
|
import org.moire.ultrasonic.domain.ChatMessage;
|
||||||
import org.moire.ultrasonic.domain.Genre;
|
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.TimeLimitedCache;
|
||||||
import org.moire.ultrasonic.util.Util;
|
import org.moire.ultrasonic.util.Util;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import kotlin.Pair;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Sindre Mehus
|
* @author Sindre Mehus
|
||||||
*/
|
*/
|
||||||
|
@ -314,7 +316,7 @@ public class CachedMusicService implements MusicService
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception
|
public Pair<InputStream, Boolean> getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception
|
||||||
{
|
{
|
||||||
return musicService.getDownloadInputStream(context, song, offset, maxBitrate, task);
|
return musicService.getDownloadInputStream(context, song, offset, maxBitrate, task);
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,10 +29,6 @@ import org.moire.ultrasonic.util.CancellableTask;
|
||||||
import org.moire.ultrasonic.util.FileUtil;
|
import org.moire.ultrasonic.util.FileUtil;
|
||||||
import org.moire.ultrasonic.util.Util;
|
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.File;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -40,6 +36,8 @@ import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.io.RandomAccessFile;
|
import java.io.RandomAccessFile;
|
||||||
|
|
||||||
|
import kotlin.Pair;
|
||||||
|
|
||||||
import static android.content.Context.POWER_SERVICE;
|
import static android.content.Context.POWER_SERVICE;
|
||||||
import static android.os.PowerManager.ON_AFTER_RELEASE;
|
import static android.os.PowerManager.ON_AFTER_RELEASE;
|
||||||
import static android.os.PowerManager.SCREEN_DIM_WAKE_LOCK;
|
import static android.os.PowerManager.SCREEN_DIM_WAKE_LOCK;
|
||||||
|
@ -66,7 +64,6 @@ public class DownloadFile
|
||||||
private volatile boolean isPlaying;
|
private volatile boolean isPlaying;
|
||||||
private volatile boolean saveWhenDone;
|
private volatile boolean saveWhenDone;
|
||||||
private volatile boolean completeWhenDone;
|
private volatile boolean completeWhenDone;
|
||||||
private Integer contentLength;
|
|
||||||
|
|
||||||
public DownloadFile(Context context, MusicDirectory.Entry song, boolean save)
|
public DownloadFile(Context context, MusicDirectory.Entry song, boolean save)
|
||||||
{
|
{
|
||||||
|
@ -105,11 +102,6 @@ public class DownloadFile
|
||||||
return song.getBitRate() == null ? 160 : song.getBitRate();
|
return song.getBitRate() == null ? 160 : song.getBitRate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getContentLength()
|
|
||||||
{
|
|
||||||
return contentLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void download()
|
public synchronized void download()
|
||||||
{
|
{
|
||||||
FileUtil.createDirectoryForParent(saveFile);
|
FileUtil.createDirectoryForParent(saveFile);
|
||||||
|
@ -369,30 +361,17 @@ public class DownloadFile
|
||||||
if (compare)
|
if (compare)
|
||||||
{
|
{
|
||||||
// Attempt partial HTTP GET, appending to the file if it exists.
|
// Attempt partial HTTP GET, appending to the file if it exists.
|
||||||
HttpResponse response = musicService.getDownloadInputStream(context, song, partialFile.length(), bitRate, DownloadTask.this);
|
Pair<InputStream, Boolean> response = musicService
|
||||||
Header contentLengthHeader = response.getFirstHeader("Content-Length");
|
.getDownloadInputStream(context, song, partialFile.length(), bitRate,
|
||||||
|
DownloadTask.this);
|
||||||
|
|
||||||
if (contentLengthHeader != null)
|
if (response.getSecond())
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
Log.i(TAG, String.format("Executed partial HTTP GET, skipping %d bytes", partialFile.length()));
|
Log.i(TAG, String.format("Executed partial HTTP GET, skipping %d bytes", partialFile.length()));
|
||||||
}
|
}
|
||||||
|
|
||||||
out = new FileOutputStream(partialFile, partial);
|
out = new FileOutputStream(partialFile, response.getSecond());
|
||||||
long n = copy(in, out);
|
long n = copy(response.getFirst(), out);
|
||||||
Log.i(TAG, String.format("Downloaded %d bytes to %s", n, partialFile));
|
Log.i(TAG, String.format("Downloaded %d bytes to %s", n, partialFile));
|
||||||
out.flush();
|
out.flush();
|
||||||
out.close();
|
out.close();
|
||||||
|
|
|
@ -39,8 +39,11 @@ import org.moire.ultrasonic.domain.UserInfo;
|
||||||
import org.moire.ultrasonic.util.CancellableTask;
|
import org.moire.ultrasonic.util.CancellableTask;
|
||||||
import org.moire.ultrasonic.util.ProgressListener;
|
import org.moire.ultrasonic.util.ProgressListener;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import kotlin.Pair;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Sindre Mehus
|
* @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;
|
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<InputStream, Boolean> getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception;
|
||||||
|
|
||||||
String getVideoUrl(Context context, String id, boolean useFlash) throws Exception;
|
String getVideoUrl(Context context, String id, boolean useFlash) throws Exception;
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,6 @@ import org.apache.http.conn.scheme.SchemeRegistry;
|
||||||
import org.apache.http.conn.scheme.SocketFactory;
|
import org.apache.http.conn.scheme.SocketFactory;
|
||||||
import org.apache.http.impl.client.DefaultHttpClient;
|
import org.apache.http.impl.client.DefaultHttpClient;
|
||||||
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
|
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
|
||||||
import org.apache.http.message.BasicHeader;
|
|
||||||
import org.apache.http.message.BasicNameValuePair;
|
import org.apache.http.message.BasicNameValuePair;
|
||||||
import org.apache.http.params.BasicHttpParams;
|
import org.apache.http.params.BasicHttpParams;
|
||||||
import org.apache.http.params.HttpConnectionParams;
|
import org.apache.http.params.HttpConnectionParams;
|
||||||
|
@ -133,13 +132,13 @@ import java.io.Reader;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import kotlin.Pair;
|
||||||
import retrofit2.Response;
|
import retrofit2.Response;
|
||||||
|
|
||||||
import static java.util.Arrays.asList;
|
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_CONNECT_TIMEOUT = 10 * 1000;
|
||||||
private static final int SOCKET_READ_TIMEOUT_DEFAULT = 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 int SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS = 60 * 1000;
|
||||||
private static final double TIMEOUT_MILLIS_PER_OFFSET_BYTE = 20000.0 / 1000000.0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL from which to fetch latest versions.
|
* URL from which to fetch latest versions.
|
||||||
|
@ -785,14 +780,7 @@ public class RESTMusicService implements MusicService
|
||||||
}
|
}
|
||||||
|
|
||||||
StreamResponse response = subsonicAPIClient.getCoverArt(id, (long) size);
|
StreamResponse response = subsonicAPIClient.getCoverArt(id, (long) size);
|
||||||
if (response.hasError() || response.getStream() == null) {
|
checkStreamResponseError(response);
|
||||||
if (response.getApiError() != null) {
|
|
||||||
throw new SubsonicRESTException(response.getApiError().getCode(), "rest error");
|
|
||||||
} else {
|
|
||||||
throw new IOException("Failed to make endpoint request, code: " +
|
|
||||||
response.getRequestErrorCode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.getStream() == null) {
|
if (response.getStream() == null) {
|
||||||
return null; // Failed to load
|
return null; // Failed to load
|
||||||
|
@ -826,47 +814,38 @@ public class RESTMusicService implements MusicService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception
|
public Pair<InputStream, Boolean> getDownloadInputStream(final Context context,
|
||||||
{
|
final MusicDirectory.Entry song,
|
||||||
|
final long offset,
|
||||||
String url = Util.getRestUrl(context, "stream");
|
final int maxBitrate,
|
||||||
|
final CancellableTask task)
|
||||||
// Set socket read timeout. Note: The timeout increases as the offset gets larger. This is
|
throws Exception {
|
||||||
// to avoid the thrashing effect seen when offset is combined with transcoding/downsampling on the server.
|
if (song == null) {
|
||||||
// In that case, the server uses a long time before sending any data, causing the client to time out.
|
throw new IllegalArgumentException("Song for download is null!");
|
||||||
HttpParams params = new BasicHttpParams();
|
|
||||||
int timeout = (int) (SOCKET_READ_TIMEOUT_DOWNLOAD + offset * TIMEOUT_MILLIS_PER_OFFSET_BYTE);
|
|
||||||
HttpConnectionParams.setSoTimeout(params, timeout);
|
|
||||||
|
|
||||||
// Add "Range" header if offset is given.
|
|
||||||
Collection<Header> headers = new ArrayList<Header>();
|
|
||||||
|
|
||||||
if (offset > 0)
|
|
||||||
{
|
|
||||||
headers.add(new BasicHeader("Range", String.format("bytes=%d-", offset)));
|
|
||||||
}
|
}
|
||||||
|
long songOffset = offset < 0 ? 0 : offset;
|
||||||
|
|
||||||
List<String> parameterNames = asList("id", "maxBitRate");
|
StreamResponse response = subsonicAPIClient.stream(song.getId(), maxBitrate, songOffset);
|
||||||
List<Object> parameterValues = Arrays.<Object>asList(song.getId(), maxBitrate);
|
checkStreamResponseError(response);
|
||||||
HttpResponse response = getResponseForURL(context, url, params, parameterNames, parameterValues, headers, null, task);
|
if (response.getStream() == null) {
|
||||||
|
throw new IOException("Null stream response");
|
||||||
|
}
|
||||||
|
Boolean partial = response.getResponseHttpCode() == 206;
|
||||||
|
|
||||||
// If content type is XML, an error occurred. Get it.
|
return new Pair<>(response.getStream(), partial);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -980,11 +959,6 @@ public class RESTMusicService implements MusicService
|
||||||
return getReader(context, progressListener, method, requestParams, Collections.<String>emptyList(), Collections.emptyList());
|
return getReader(context, progressListener, method, requestParams, Collections.<String>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<String> parameterNames, List<Object> parameterValues) throws Exception
|
private Reader getReader(Context context, ProgressListener progressListener, String method, HttpParams requestParams, List<String> parameterNames, List<Object> parameterValues) throws Exception
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue