Cleaner separation of API result handling.

* CallResponseChecker functionality are now Kotlin Extensions
* Removed unfitting shortcuts from SubsonicAPIClient
* Increase timeout to get a video url
* Fix a bug, that the Rest API version was checked twice on each call
This commit is contained in:
tzugen 2021-06-09 12:19:34 +02:00
parent dd1f55a927
commit d9a7fa2413
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
21 changed files with 340 additions and 364 deletions

View File

@ -10,7 +10,7 @@ import org.moire.ultrasonic.api.subsonic.interceptors.toHexBytes
import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
/** /**
* Integration test for [SubsonicAPIClient.getStreamUrl] method. * Integration test for [getStreamUrl] method.
*/ */
class GetStreamUrlTest { class GetStreamUrlTest {
@JvmField @Rule val mockWebServerRule = MockWebServerRule() @JvmField @Rule val mockWebServerRule = MockWebServerRule()
@ -30,7 +30,7 @@ class GetStreamUrlTest {
) )
client = SubsonicAPIClient(config) client = SubsonicAPIClient(config)
val baseExpectedUrl = mockWebServerRule.mockWebServer.url("").toString() val baseExpectedUrl = mockWebServerRule.mockWebServer.url("").toString()
expectedUrl = "$baseExpectedUrl/rest/stream.view?id=$id&u=$USERNAME" + expectedUrl = "$baseExpectedUrl/rest/stream.view?id=$id&format=raw&u=$USERNAME" +
"&c=$CLIENT_ID&f=json&v=${V1_6_0.restApiVersion}&p=enc:${PASSWORD.toHexBytes()}" "&c=$CLIENT_ID&f=json&v=${V1_6_0.restApiVersion}&p=enc:${PASSWORD.toHexBytes()}"
} }
@ -38,7 +38,7 @@ class GetStreamUrlTest {
fun `Should return valid stream url`() { fun `Should return valid stream url`() {
mockWebServerRule.enqueueResponse("ping_ok.json") mockWebServerRule.enqueueResponse("ping_ok.json")
val streamUrl = client.getStreamUrl(id) val streamUrl = client.api.getStreamUrl(id)
streamUrl `should be equal to` expectedUrl streamUrl `should be equal to` expectedUrl
} }
@ -47,7 +47,7 @@ class GetStreamUrlTest {
fun `Should still return stream url if connection failed`() { fun `Should still return stream url if connection failed`() {
mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(500)) mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(500))
val streamUrl = client.getStreamUrl(id) val streamUrl = client.api.getStreamUrl(id)
streamUrl `should be equal to` expectedUrl streamUrl `should be equal to` expectedUrl
} }

View File

@ -7,14 +7,14 @@ import org.amshove.kluent.`should not be`
import org.junit.Test import org.junit.Test
/** /**
* Integration test for [SubsonicAPIClient.getAvatar] call. * Integration test for [SubsonicAPIDefinition.getAvatar] call.
*/ */
class SubsonicApiGetAvatarTest : SubsonicAPIClientTest() { class SubsonicApiGetAvatarTest : SubsonicAPIClientTest() {
@Test @Test
fun `Should handle api error response`() { fun `Should handle api error response`() {
mockWebServerRule.enqueueResponse("request_data_not_found_error_response.json") mockWebServerRule.enqueueResponse("request_data_not_found_error_response.json")
val response = client.getAvatar("some") val response = client.api.getAvatar("some-id").execute().toStreamResponse()
with(response) { with(response) {
stream `should be` null stream `should be` null
@ -28,7 +28,7 @@ class SubsonicApiGetAvatarTest : SubsonicAPIClientTest() {
val httpErrorCode = 500 val httpErrorCode = 500
mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(httpErrorCode)) mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(httpErrorCode))
val response = client.getAvatar("some") val response = client.api.getAvatar("some-id").execute().toStreamResponse()
with(response) { with(response) {
stream `should be equal to` null stream `should be equal to` null
@ -44,7 +44,7 @@ class SubsonicApiGetAvatarTest : SubsonicAPIClientTest() {
.setBody(mockWebServerRule.loadJsonResponse("ping_ok.json")) .setBody(mockWebServerRule.loadJsonResponse("ping_ok.json"))
) )
val response = client.stream("some") val response = client.api.stream("some-id").execute().toStreamResponse()
with(response) { with(response) {
responseHttpCode `should be equal to` 200 responseHttpCode `should be equal to` 200

View File

@ -14,7 +14,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() {
fun `Should handle api error response`() { fun `Should handle api error response`() {
mockWebServerRule.enqueueResponse("request_data_not_found_error_response.json") mockWebServerRule.enqueueResponse("request_data_not_found_error_response.json")
val response = client.getCoverArt("some-id") val response = client.api.getCoverArt("some-id").execute().toStreamResponse()
with(response) { with(response) {
stream `should be` null stream `should be` null
@ -28,7 +28,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() {
val httpErrorCode = 404 val httpErrorCode = 404
mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(httpErrorCode)) mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(httpErrorCode))
val response = client.getCoverArt("some-id") val response = client.api.getCoverArt("some-id").execute().toStreamResponse()
with(response) { with(response) {
stream `should be` null stream `should be` null
@ -44,7 +44,7 @@ class SubsonicApiGetCoverArtTest : SubsonicAPIClientTest() {
.setBody(mockWebServerRule.loadJsonResponse("ping_ok.json")) .setBody(mockWebServerRule.loadJsonResponse("ping_ok.json"))
) )
val response = client.getCoverArt("some-id") val response = client.api.getCoverArt("some-id").execute().toStreamResponse()
with(response) { with(response) {
responseHttpCode `should be equal to` 200 responseHttpCode `should be equal to` 200

View File

@ -14,7 +14,7 @@ class SubsonicApiStreamTest : SubsonicAPIClientTest() {
fun `Should handle api error response`() { fun `Should handle api error response`() {
mockWebServerRule.enqueueResponse("request_data_not_found_error_response.json") mockWebServerRule.enqueueResponse("request_data_not_found_error_response.json")
val response = client.stream("some-id") val response = client.api.stream("some-id").execute().toStreamResponse()
with(response) { with(response) {
stream `should be` null stream `should be` null
@ -28,7 +28,7 @@ class SubsonicApiStreamTest : SubsonicAPIClientTest() {
val httpErrorCode = 404 val httpErrorCode = 404
mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(httpErrorCode)) mockWebServerRule.mockWebServer.enqueue(MockResponse().setResponseCode(httpErrorCode))
val response = client.stream("some-id") val response = client.api.stream("some-id").execute().toStreamResponse()
with(response) { with(response) {
stream `should be` null stream `should be` null
@ -38,13 +38,13 @@ class SubsonicApiStreamTest : SubsonicAPIClientTest() {
} }
@Test @Test
fun `Should return successfull call stream`() { fun `Should return successful call stream`() {
mockWebServerRule.mockWebServer.enqueue( mockWebServerRule.mockWebServer.enqueue(
MockResponse() MockResponse()
.setBody(mockWebServerRule.loadJsonResponse("ping_ok.json")) .setBody(mockWebServerRule.loadJsonResponse("ping_ok.json"))
) )
val response = client.stream("some-id") val response = client.api.stream("some-id").execute().toStreamResponse()
with(response) { with(response) {
responseHttpCode `should be equal to` 200 responseHttpCode `should be equal to` 200

View File

@ -0,0 +1,97 @@
package org.moire.ultrasonic.api.subsonic
import com.fasterxml.jackson.module.kotlin.readValue
import java.io.IOException
import okhttp3.ResponseBody
import org.moire.ultrasonic.api.subsonic.response.StreamResponse
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import retrofit2.Response
/**
* Converts a Response to a StreamResponse
*/
fun Response<out ResponseBody>.toStreamResponse(): StreamResponse {
val response = this
return if (response.isSuccessful) {
val responseBody = response.body()
val contentType = responseBody?.contentType()
if (
contentType != null &&
contentType.type().equals("application", true) &&
contentType.subtype().equals("json", true)
) {
val error = SubsonicAPIClient.jacksonMapper.readValue<SubsonicResponse>(
responseBody.byteStream()
)
StreamResponse(apiError = error.error, responseHttpCode = response.code())
} else {
StreamResponse(
stream = responseBody?.byteStream(),
responseHttpCode = response.code()
)
}
} else {
StreamResponse(responseHttpCode = response.code())
}
}
/**
* This call wraps Subsonic API calls so their results can be checked for errors, API version, etc
* It creates Exceptions from the results returned by the Subsonic API
*/
@Suppress("ThrowsCount")
fun Response<out SubsonicResponse>.throwOnFailure(): Response<out SubsonicResponse> {
val response = this
if (response.isSuccessful && response.body()!!.status === SubsonicResponse.Status.OK) {
return this
}
if (!response.isSuccessful) {
throw IOException("Server error, code: " + response.code())
} else if (
response.body()!!.status === SubsonicResponse.Status.ERROR &&
response.body()!!.error != null
) {
throw SubsonicRESTException(response.body()!!.error!!)
} else {
throw IOException("Failed to perform request: " + response.code())
}
}
fun Response<out SubsonicResponse>.falseOnFailure(): Boolean {
return (this.isSuccessful && this.body()!!.status === SubsonicResponse.Status.OK)
}
/**
* This call wraps Subsonic API calls so their results can be checked for errors, API version, etc
* It creates Exceptions from a StreamResponse
*/
fun StreamResponse.throwOnFailure(): StreamResponse {
val response = this
if (response.hasError() || response.stream == null) {
if (response.apiError != null) {
throw SubsonicRESTException(response.apiError)
} else {
throw IOException(
"Failed to make endpoint request, code: " + response.responseHttpCode
)
}
}
return this
}
/**
* Gets a stream url.
*
* Calling this method do actual connection to the backend, though not downloading all content.
*
* Consider do not use this method, but [SubsonicAPIDefinition.stream] call.
*/
fun SubsonicAPIDefinition.getStreamUrl(id: String): String {
val response = this.stream(id, format = "raw").execute()
val url = response.raw().request().url().toString()
if (response.isSuccessful) {
response.body()?.close()
}
return url
}

View File

@ -3,23 +3,18 @@ package org.moire.ultrasonic.api.subsonic
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.TimeUnit.MILLISECONDS
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.moire.ultrasonic.api.subsonic.interceptors.PasswordHexInterceptor import org.moire.ultrasonic.api.subsonic.interceptors.PasswordHexInterceptor
import org.moire.ultrasonic.api.subsonic.interceptors.PasswordMD5Interceptor import org.moire.ultrasonic.api.subsonic.interceptors.PasswordMD5Interceptor
import org.moire.ultrasonic.api.subsonic.interceptors.ProxyPasswordInterceptor import org.moire.ultrasonic.api.subsonic.interceptors.ProxyPasswordInterceptor
import org.moire.ultrasonic.api.subsonic.interceptors.RangeHeaderInterceptor import org.moire.ultrasonic.api.subsonic.interceptors.RangeHeaderInterceptor
import org.moire.ultrasonic.api.subsonic.interceptors.VersionInterceptor import org.moire.ultrasonic.api.subsonic.interceptors.VersionInterceptor
import org.moire.ultrasonic.api.subsonic.response.StreamResponse
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import retrofit2.Response
import retrofit2.Retrofit import retrofit2.Retrofit
private const val READ_TIMEOUT = 60_000L private const val READ_TIMEOUT = 60_000L
@ -48,8 +43,11 @@ class SubsonicAPIClient(
config.enableLdapUserSupport config.enableLdapUserSupport
) )
var onProtocolChange: (SubsonicAPIVersions) -> Unit = {}
/** /**
* Get currently used protocol version. * The currently used protocol version.
* The setter also updates the interceptors and callback (if registered)
*/ */
var protocolVersion = config.minimalProtocolVersion var protocolVersion = config.minimalProtocolVersion
private set(value) { private set(value) {
@ -57,6 +55,7 @@ class SubsonicAPIClient(
proxyPasswordInterceptor.apiVersion = field proxyPasswordInterceptor.apiVersion = field
wrappedApi.currentApiVersion = field wrappedApi.currentApiVersion = field
versionInterceptor.protocolVersion = field versionInterceptor.protocolVersion = field
onProtocolChange(field)
} }
private val okHttpClient = baseOkClient.newBuilder() private val okHttpClient = baseOkClient.newBuilder()
@ -78,18 +77,19 @@ class SubsonicAPIClient(
.apply { if (config.debug) addLogging() } .apply { if (config.debug) addLogging() }
.build() .build()
private val jacksonMapper = ObjectMapper() // Create the Retrofit instance, and register a special converter factory
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true) // It will update our protocol version to the correct version, once we made a successful call
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
.registerModule(KotlinModule())
private val retrofit = Retrofit.Builder() private val retrofit = Retrofit.Builder()
.baseUrl("${config.baseUrl}/rest/") .baseUrl("${config.baseUrl}/rest/")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory( .addConverterFactory(
VersionAwareJacksonConverterFactory.create( VersionAwareJacksonConverterFactory.create(
{ protocolVersion = it }, {
// Only trigger update on change
if (protocolVersion != it) {
protocolVersion = it
}
},
jacksonMapper jacksonMapper
) )
) )
@ -102,85 +102,6 @@ class SubsonicAPIClient(
val api: SubsonicAPIDefinition get() = wrappedApi val api: SubsonicAPIDefinition get() = wrappedApi
/**
* TODO: Remove this in favour of handling the stream response inside RESTService
* Convenient method to get cover art from api using item [id] and optional maximum [size].
*
* It detects the response `Content-Type` and tries to parse subsonic error if there is one.
*
* Prefer this method over [SubsonicAPIDefinition.getCoverArt] as this handles error cases.
*/
fun getCoverArt(id: String, size: Long? = null): StreamResponse = handleStreamResponse {
api.getCoverArt(id, size).execute()
}
/**
* TODO: Remove this in favour of handling the stream response inside RESTService
* 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()
}
/**
* TODO: Remove this in favour of handling the stream response inside RESTService
* Convenient method to get user avatar using [username].
*
* It detects the response `Content-Type` and tries to parse subsonic error if there is one.
*
* Prefer this method over [SubsonicAPIDefinition.getAvatar] as this handles error cases.
*/
fun getAvatar(username: String): StreamResponse = handleStreamResponse {
api.getAvatar(username).execute()
}
// TODO: Move this to response checker
private inline fun handleStreamResponse(apiCall: () -> Response<ResponseBody>): StreamResponse {
val response = apiCall()
return if (response.isSuccessful) {
val responseBody = response.body()
val contentType = responseBody?.contentType()
if (
contentType != null &&
contentType.type().equals("application", true) &&
contentType.subtype().equals("json", true)
) {
val error = jacksonMapper.readValue<SubsonicResponse>(responseBody.byteStream())
StreamResponse(apiError = error.error, responseHttpCode = response.code())
} else {
StreamResponse(
stream = responseBody?.byteStream(),
responseHttpCode = response.code()
)
}
} else {
StreamResponse(responseHttpCode = response.code())
}
}
/**
* Get stream url.
*
* Calling this method do actual connection to the backend, though not downloading all content.
*
* Consider do not use this method, but [stream] call.
*/
fun getStreamUrl(id: String): String {
val request = api.stream(id).execute()
val url = request.raw().request().url().toString()
if (request.isSuccessful) {
request.body()?.close()
}
return url
}
private fun OkHttpClient.Builder.addLogging() { private fun OkHttpClient.Builder.addLogging() {
val loggingInterceptor = HttpLoggingInterceptor(okLogger) val loggingInterceptor = HttpLoggingInterceptor(okLogger)
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
@ -202,4 +123,12 @@ class SubsonicAPIClient(
hostnameVerifier { _, _ -> true } hostnameVerifier { _, _ -> true }
} }
companion object {
val jacksonMapper: ObjectMapper = ObjectMapper()
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
.registerModule(KotlinModule())
}
} }

View File

@ -1,6 +1,4 @@
package org.moire.ultrasonic.service package org.moire.ultrasonic.api.subsonic
import org.moire.ultrasonic.api.subsonic.SubsonicError
/** /**
* Exception returned by API with given `code`. * Exception returned by API with given `code`.

View File

@ -63,7 +63,6 @@ class VersionAwareJacksonConverterFactory(
} }
} }
@Suppress("SwallowedException")
class VersionAwareResponseBodyConverter<T> ( class VersionAwareResponseBodyConverter<T> (
private val notifier: (SubsonicAPIVersions) -> Unit = {}, private val notifier: (SubsonicAPIVersions) -> Unit = {},
private val adapter: ObjectReader private val adapter: ObjectReader
@ -77,7 +76,7 @@ class VersionAwareJacksonConverterFactory(
if (response is SubsonicResponse) { if (response is SubsonicResponse) {
try { try {
notifier(response.version) notifier(response.version)
} catch (e: IllegalArgumentException) { } catch (ignored: IllegalArgumentException) {
// no-op // no-op
} }
} }

View File

@ -30,6 +30,7 @@ import android.widget.Toast;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.moire.ultrasonic.R; import org.moire.ultrasonic.R;
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException; import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException;
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException;
import org.moire.ultrasonic.app.UApp; import org.moire.ultrasonic.app.UApp;
import org.moire.ultrasonic.data.ActiveServerProvider; import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.JukeboxStatus; import org.moire.ultrasonic.domain.JukeboxStatus;

View File

@ -37,12 +37,15 @@ class ActiveServerProvider(
cachedServer = repository.findById(serverId) cachedServer = repository.findById(serverId)
} }
Timber.d( Timber.d(
"getActiveServer retrieved from DataBase, id: $serverId; " + "getActiveServer retrieved from DataBase, id: %s cachedServer: %s",
"cachedServer: $cachedServer" serverId, cachedServer
) )
} }
if (cachedServer != null) return cachedServer!! if (cachedServer != null) {
return cachedServer!!
}
setActiveServerId(0) setActiveServerId(0)
} }
@ -105,7 +108,7 @@ class ActiveServerProvider(
* @param method: The Rest resource to use * @param method: The Rest resource to use
* @return The Rest Url of the method on the server * @return The Rest Url of the method on the server
*/ */
fun getRestUrl(method: String?): String? { fun getRestUrl(method: String?): String {
val builder = StringBuilder(8192) val builder = StringBuilder(8192)
val activeServer = getActiveServer() val activeServer = getActiveServer()
val serverUrl: String = activeServer.url val serverUrl: String = activeServer.url

View File

@ -14,7 +14,6 @@ import org.moire.ultrasonic.cache.PermanentFileStorage
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.imageloader.ImageLoader import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.log.TimberOkHttpLogger import org.moire.ultrasonic.log.TimberOkHttpLogger
import org.moire.ultrasonic.service.ApiCallResponseChecker
import org.moire.ultrasonic.service.CachedMusicService import org.moire.ultrasonic.service.CachedMusicService
import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.OfflineMusicService import org.moire.ultrasonic.service.OfflineMusicService
@ -68,10 +67,9 @@ val musicServiceModule = module {
single<HttpLoggingInterceptor.Logger> { TimberOkHttpLogger() } single<HttpLoggingInterceptor.Logger> { TimberOkHttpLogger() }
single { SubsonicAPIClient(get(), get()) } single { SubsonicAPIClient(get(), get()) }
single { ApiCallResponseChecker(get(), get()) }
single<MusicService>(named(ONLINE_MUSIC_SERVICE)) { single<MusicService>(named(ONLINE_MUSIC_SERVICE)) {
CachedMusicService(RESTMusicService(get(), get(), get(), get())) CachedMusicService(RESTMusicService(get(), get(), get()))
} }
single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) { single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) {

View File

@ -22,12 +22,13 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
import org.moire.ultrasonic.api.subsonic.falseOnFailure
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.service.ApiCallResponseChecker
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.SubsonicRESTException
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.ErrorDialog import org.moire.ultrasonic.util.ErrorDialog
import org.moire.ultrasonic.util.ModalBackgroundTask import org.moire.ultrasonic.util.ModalBackgroundTask
@ -360,7 +361,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
// Execute a ping to check the authentication, now using the correct API version. // Execute a ping to check the authentication, now using the correct API version.
pingResponse = subsonicApiClient.api.ping().execute() pingResponse = subsonicApiClient.api.ping().execute()
ApiCallResponseChecker.checkResponseSuccessful(pingResponse) pingResponse.throwOnFailure()
currentServerSetting!!.chatSupport = isServerFunctionAvailable { currentServerSetting!!.chatSupport = isServerFunctionAvailable {
subsonicApiClient.api.getChatMessages().execute() subsonicApiClient.api.getChatMessages().execute()
@ -387,7 +388,8 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
updateProgress(getProgress()) updateProgress(getProgress())
val licenseResponse = subsonicApiClient.api.getLicense().execute() val licenseResponse = subsonicApiClient.api.getLicense().execute()
ApiCallResponseChecker.checkResponseSuccessful(licenseResponse) licenseResponse.throwOnFailure()
if (!licenseResponse.body()!!.license.valid) { if (!licenseResponse.body()!!.license.valid) {
return getProgress() + "\n" + return getProgress() + "\n" +
resources.getString(R.string.settings_testing_unlicensed) resources.getString(R.string.settings_testing_unlicensed)
@ -438,9 +440,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
private fun isServerFunctionAvailable(function: () -> Response<out SubsonicResponse>): Boolean { private fun isServerFunctionAvailable(function: () -> Response<out SubsonicResponse>): Boolean {
return try { return try {
val response = function() function().falseOnFailure()
ApiCallResponseChecker.checkResponseSuccessful(response)
true
} catch (_: IOException) { } catch (_: IOException) {
false false
} catch (_: SubsonicRESTException) { } catch (_: SubsonicRESTException) {

View File

@ -6,6 +6,7 @@ import com.squareup.picasso.RequestHandler
import java.io.IOException import java.io.IOException
import okio.Okio import okio.Okio
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.toStreamResponse
/** /**
* Loads avatars from subsonic api. * Loads avatars from subsonic api.
@ -23,7 +24,7 @@ class AvatarRequestHandler(
val username = request.uri.getQueryParameter(QUERY_USERNAME) val username = request.uri.getQueryParameter(QUERY_USERNAME)
?: throw IllegalArgumentException("Nullable username") ?: throw IllegalArgumentException("Nullable username")
val response = apiClient.getAvatar(username) val response = apiClient.api.getAvatar(username).execute().toStreamResponse()
if (response.hasError() || response.stream == null) { if (response.hasError() || response.stream == null) {
throw IOException("${response.apiError}") throw IOException("${response.apiError}")
} else { } else {

View File

@ -7,13 +7,14 @@ import com.squareup.picasso.RequestHandler
import java.io.IOException import java.io.IOException
import okio.Okio import okio.Okio
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.util.FileUtil.SUFFIX_LARGE import org.moire.ultrasonic.util.FileUtil.SUFFIX_LARGE
import org.moire.ultrasonic.util.FileUtil.SUFFIX_SMALL import org.moire.ultrasonic.util.FileUtil.SUFFIX_SMALL
/** /**
* Loads cover arts from subsonic api. * Loads cover arts from subsonic api.
*/ */
class CoverArtRequestHandler(private val apiClient: SubsonicAPIClient) : RequestHandler() { class CoverArtRequestHandler(private val client: SubsonicAPIClient) : RequestHandler() {
override fun canHandleRequest(data: Request): Boolean { override fun canHandleRequest(data: Request): Boolean {
return with(data.uri) { return with(data.uri) {
scheme == SCHEME && scheme == SCHEME &&
@ -38,7 +39,9 @@ class CoverArtRequestHandler(private val apiClient: SubsonicAPIClient) : Request
} }
// Try to fetch the image from the API // Try to fetch the image from the API
val response = apiClient.getCoverArt(id, size) val response = client.api.getCoverArt(id, size).execute().toStreamResponse()
// Handle the response
if (!response.hasError() && response.stream != null) { if (!response.hasError() && response.stream != null) {
return Result(Okio.source(response.stream!!), NETWORK) return Result(Okio.source(response.stream!!), NETWORK)
} }

View File

@ -13,8 +13,9 @@ import java.io.OutputStream
import org.moire.ultrasonic.BuildConfig import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.RESTMusicService
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 timber.log.Timber import timber.log.Timber
@ -24,9 +25,12 @@ import timber.log.Timber
*/ */
class ImageLoader( class ImageLoader(
context: Context, context: Context,
private val apiClient: SubsonicAPIClient, apiClient: SubsonicAPIClient,
private val config: ImageLoaderConfig private val config: ImageLoaderConfig
) { ) {
// Shortcut
@Suppress("VariableNaming", "PropertyName")
val API = apiClient.api
private val picasso = Picasso.Builder(context) private val picasso = Picasso.Builder(context)
.addRequestHandler(CoverArtRequestHandler(apiClient)) .addRequestHandler(CoverArtRequestHandler(apiClient))
@ -143,8 +147,8 @@ class ImageLoader(
// Query the API // Query the API
Timber.d("Loading cover art for: %s", entry) Timber.d("Loading cover art for: %s", entry)
val response = apiClient.getCoverArt(id!!, size.toLong()) val response = API.getCoverArt(id!!, size.toLong()).execute().toStreamResponse()
RESTMusicService.checkStreamResponseError(response) response.throwOnFailure()
// Check for failure // Check for failure
if (response.stream == null) return if (response.stream == null) return

View File

@ -1,66 +0,0 @@
package org.moire.ultrasonic.service
import java.io.IOException
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.SubsonicAPIDefinition
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import org.moire.ultrasonic.data.ActiveServerProvider
import retrofit2.Response
import timber.log.Timber
/**
* This call wraps Subsonic API calls so their results can be checked for errors, API version, etc
*/
class ApiCallResponseChecker(
private val subsonicAPIClient: SubsonicAPIClient,
private val activeServerProvider: ActiveServerProvider
) {
/**
* Executes a Subsonic API call with response check
*/
@Throws(SubsonicRESTException::class, IOException::class)
fun <T : Response<out SubsonicResponse>> callWithResponseCheck(
call: (SubsonicAPIDefinition) -> T
): T {
// Check for API version when first contacting the server
if (activeServerProvider.getActiveServer().minimumApiVersion == null) {
try {
val response = subsonicAPIClient.api.ping().execute()
if (response.body() != null) {
val restApiVersion = response.body()!!.version.restApiVersion
Timber.i("Server minimum API version set to %s", restApiVersion)
activeServerProvider.setMinimumApiVersion(restApiVersion)
}
} catch (ignored: Exception) {
// This Ping is only used to get the API Version, if it fails, that's no problem.
}
}
// This call will be now executed with the correct API Version, so it shouldn't fail
val result = call.invoke(subsonicAPIClient.api)
checkResponseSuccessful(result)
return result
}
/**
* Creates Exceptions from the results returned by the Subsonic API
*/
companion object {
@Throws(SubsonicRESTException::class, IOException::class)
fun checkResponseSuccessful(response: Response<out SubsonicResponse>) {
if (response.isSuccessful && response.body()!!.status === SubsonicResponse.Status.OK) {
return
}
if (!response.isSuccessful) {
throw IOException("Server error, code: " + response.code())
} else if (
response.body()!!.status === SubsonicResponse.Status.ERROR &&
response.body()!!.error != null
) {
throw SubsonicRESTException(response.body()!!.error!!)
} else {
throw IOException("Failed to perform request: " + response.code())
}
}
}
}

View File

@ -28,6 +28,7 @@ import java.security.cert.CertificateException
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage import org.moire.ultrasonic.subsonic.getLocalizedErrorMessage
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber

View File

@ -13,11 +13,14 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.getStreamUrl
import org.moire.ultrasonic.api.subsonic.models.AlbumListType.Companion.fromName import org.moire.ultrasonic.api.subsonic.models.AlbumListType.Companion.fromName
import org.moire.ultrasonic.api.subsonic.models.JukeboxAction import org.moire.ultrasonic.api.subsonic.models.JukeboxAction
import org.moire.ultrasonic.api.subsonic.response.StreamResponse import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.cache.PermanentFileStorage import org.moire.ultrasonic.cache.PermanentFileStorage
import org.moire.ultrasonic.cache.serializers.getIndexesSerializer import org.moire.ultrasonic.cache.serializers.getIndexesSerializer
import org.moire.ultrasonic.cache.serializers.getMusicFolderListSerializer import org.moire.ultrasonic.cache.serializers.getMusicFolderListSerializer
@ -50,20 +53,24 @@ import timber.log.Timber
*/ */
@Suppress("LargeClass") @Suppress("LargeClass")
open class RESTMusicService( open class RESTMusicService(
private val subsonicAPIClient: SubsonicAPIClient, subsonicAPIClient: SubsonicAPIClient,
private val fileStorage: PermanentFileStorage, private val fileStorage: PermanentFileStorage,
private val activeServerProvider: ActiveServerProvider, private val activeServerProvider: ActiveServerProvider
private val responseChecker: ApiCallResponseChecker
) : MusicService { ) : MusicService {
// Shortcut to the API
@Suppress("VariableNaming", "PropertyName")
val API = subsonicAPIClient.api
@Throws(Exception::class) @Throws(Exception::class)
override fun ping() { override fun ping() {
responseChecker.callWithResponseCheck { api -> api.ping().execute() } API.ping().execute().throwOnFailure()
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun isLicenseValid(): Boolean { override fun isLicenseValid(): Boolean {
val response = responseChecker.callWithResponseCheck { api -> api.getLicense().execute() } val response = API.getLicense().execute()
response.throwOnFailure()
return response.body()!!.license.valid return response.body()!!.license.valid
} }
@ -78,9 +85,8 @@ open class RESTMusicService(
if (cachedMusicFolders != null && !refresh) return cachedMusicFolders if (cachedMusicFolders != null && !refresh) return cachedMusicFolders
val response = responseChecker.callWithResponseCheck { api -> val response = API.getMusicFolders().execute()
api.getMusicFolders().execute() response.throwOnFailure()
}
val musicFolders = response.body()!!.musicFolders.toDomainEntityList() val musicFolders = response.body()!!.musicFolders.toDomainEntityList()
fileStorage.store(MUSIC_FOLDER_STORAGE_NAME, musicFolders, getMusicFolderListSerializer()) fileStorage.store(MUSIC_FOLDER_STORAGE_NAME, musicFolders, getMusicFolderListSerializer())
@ -98,9 +104,8 @@ open class RESTMusicService(
val cachedIndexes = fileStorage.load(indexName, getIndexesSerializer()) val cachedIndexes = fileStorage.load(indexName, getIndexesSerializer())
if (cachedIndexes != null && !refresh) return cachedIndexes if (cachedIndexes != null && !refresh) return cachedIndexes
val response = responseChecker.callWithResponseCheck { api -> val response = API.getIndexes(musicFolderId, null).execute()
api.getIndexes(musicFolderId, null).execute() response.throwOnFailure()
}
val indexes = response.body()!!.indexes.toDomainEntity() val indexes = response.body()!!.indexes.toDomainEntity()
fileStorage.store(indexName, indexes, getIndexesSerializer()) fileStorage.store(indexName, indexes, getIndexesSerializer())
@ -114,9 +119,8 @@ open class RESTMusicService(
val cachedArtists = fileStorage.load(ARTISTS_STORAGE_NAME, getIndexesSerializer()) val cachedArtists = fileStorage.load(ARTISTS_STORAGE_NAME, getIndexesSerializer())
if (cachedArtists != null && !refresh) return cachedArtists if (cachedArtists != null && !refresh) return cachedArtists
val response = responseChecker.callWithResponseCheck { api -> val response = API.getArtists(null).execute()
api.getArtists(null).execute() response.throwOnFailure()
}
val indexes = response.body()!!.indexes.toDomainEntity() val indexes = response.body()!!.indexes.toDomainEntity()
fileStorage.store(ARTISTS_STORAGE_NAME, indexes, getIndexesSerializer()) fileStorage.store(ARTISTS_STORAGE_NAME, indexes, getIndexesSerializer())
@ -129,7 +133,7 @@ open class RESTMusicService(
albumId: String?, albumId: String?,
artistId: String? artistId: String?
) { ) {
responseChecker.callWithResponseCheck { api -> api.star(id, albumId, artistId).execute() } API.star(id, albumId, artistId).execute().throwOnFailure()
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -138,7 +142,7 @@ open class RESTMusicService(
albumId: String?, albumId: String?,
artistId: String? artistId: String?
) { ) {
responseChecker.callWithResponseCheck { api -> api.unstar(id, albumId, artistId).execute() } API.unstar(id, albumId, artistId).execute().throwOnFailure()
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -146,7 +150,7 @@ open class RESTMusicService(
id: String, id: String,
rating: Int rating: Int
) { ) {
responseChecker.callWithResponseCheck { api -> api.setRating(id, rating).execute() } API.setRating(id, rating).execute().throwOnFailure()
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -155,9 +159,8 @@ open class RESTMusicService(
name: String?, name: String?,
refresh: Boolean refresh: Boolean
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getMusicDirectory(id).execute()
api.getMusicDirectory(id).execute() response.throwOnFailure()
}
return response.body()!!.musicDirectory.toDomainEntity() return response.body()!!.musicDirectory.toDomainEntity()
} }
@ -168,7 +171,8 @@ open class RESTMusicService(
name: String?, name: String?,
refresh: Boolean refresh: Boolean
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> api.getArtist(id).execute() } val response = API.getArtist(id).execute()
response.throwOnFailure()
return response.body()!!.artist.toMusicDirectoryDomainEntity() return response.body()!!.artist.toMusicDirectoryDomainEntity()
} }
@ -179,7 +183,8 @@ open class RESTMusicService(
name: String?, name: String?,
refresh: Boolean refresh: Boolean
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> api.getAlbum(id).execute() } val response = API.getAlbum(id).execute()
response.throwOnFailure()
return response.body()!!.album.toMusicDirectoryDomainEntity() return response.body()!!.album.toMusicDirectoryDomainEntity()
} }
@ -207,10 +212,10 @@ open class RESTMusicService(
private fun searchOld( private fun searchOld(
criteria: SearchCriteria criteria: SearchCriteria
): SearchResult { ): SearchResult {
val response = responseChecker.callWithResponseCheck { api -> val response =
api.search(null, null, null, criteria.query, criteria.songCount, null, null) API.search(null, null, null, criteria.query, criteria.songCount, null, null)
.execute() .execute()
} response.throwOnFailure()
return response.body()!!.searchResult.toDomainEntity() return response.body()!!.searchResult.toDomainEntity()
} }
@ -223,12 +228,12 @@ open class RESTMusicService(
criteria: SearchCriteria criteria: SearchCriteria
): SearchResult { ): SearchResult {
requireNotNull(criteria.query) { "Query param is null" } requireNotNull(criteria.query) { "Query param is null" }
val response = responseChecker.callWithResponseCheck { api -> val response = API.search2(
api.search2( criteria.query, criteria.artistCount, null, criteria.albumCount, null,
criteria.query, criteria.artistCount, null, criteria.albumCount, null, criteria.songCount, null
criteria.songCount, null ).execute()
).execute()
} response.throwOnFailure()
return response.body()!!.searchResult.toDomainEntity() return response.body()!!.searchResult.toDomainEntity()
} }
@ -238,12 +243,12 @@ open class RESTMusicService(
criteria: SearchCriteria criteria: SearchCriteria
): SearchResult { ): SearchResult {
requireNotNull(criteria.query) { "Query param is null" } requireNotNull(criteria.query) { "Query param is null" }
val response = responseChecker.callWithResponseCheck { api -> val response = API.search3(
api.search3( criteria.query, criteria.artistCount, null, criteria.albumCount, null,
criteria.query, criteria.artistCount, null, criteria.albumCount, null, criteria.songCount, null
criteria.songCount, null ).execute()
).execute()
} response.throwOnFailure()
return response.body()!!.searchResult.toDomainEntity() return response.body()!!.searchResult.toDomainEntity()
} }
@ -253,9 +258,8 @@ open class RESTMusicService(
id: String, id: String,
name: String name: String
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getPlaylist(id).execute()
api.getPlaylist(id).execute() response.throwOnFailure()
}
val playlist = response.body()!!.playlist.toMusicDirectoryDomainEntity() val playlist = response.body()!!.playlist.toMusicDirectoryDomainEntity()
savePlaylist(name, playlist) savePlaylist(name, playlist)
@ -300,9 +304,8 @@ open class RESTMusicService(
override fun getPlaylists( override fun getPlaylists(
refresh: Boolean refresh: Boolean
): List<Playlist> { ): List<Playlist> {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getPlaylists(null).execute()
api.getPlaylists(null).execute() response.throwOnFailure()
}
return response.body()!!.playlists.toDomainEntitiesList() return response.body()!!.playlists.toDomainEntitiesList()
} }
@ -318,16 +321,15 @@ open class RESTMusicService(
for ((id1) in entries) { for ((id1) in entries) {
pSongIds.add(id1) pSongIds.add(id1)
} }
responseChecker.callWithResponseCheck { api ->
api.createPlaylist(id, name, pSongIds.toList()).execute() API.createPlaylist(id, name, pSongIds.toList()).execute().throwOnFailure()
}
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun deletePlaylist( override fun deletePlaylist(
id: String id: String
) { ) {
responseChecker.callWithResponseCheck { api -> api.deletePlaylist(id).execute() } API.deletePlaylist(id).execute().throwOnFailure()
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -337,19 +339,16 @@ open class RESTMusicService(
comment: String?, comment: String?,
pub: Boolean pub: Boolean
) { ) {
responseChecker.callWithResponseCheck { api -> API.updatePlaylist(id, name, comment, pub, null, null)
api.updatePlaylist(id, name, comment, pub, null, null) .execute().throwOnFailure()
.execute()
}
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getPodcastsChannels( override fun getPodcastsChannels(
refresh: Boolean refresh: Boolean
): List<PodcastsChannel> { ): List<PodcastsChannel> {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getPodcasts(false, null).execute()
api.getPodcasts(false, null).execute() response.throwOnFailure()
}
return response.body()!!.podcastChannels.toDomainEntitiesList() return response.body()!!.podcastChannels.toDomainEntitiesList()
} }
@ -358,9 +357,8 @@ open class RESTMusicService(
override fun getPodcastEpisodes( override fun getPodcastEpisodes(
podcastChannelId: String? podcastChannelId: String?
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getPodcasts(true, podcastChannelId).execute()
api.getPodcasts(true, podcastChannelId).execute() response.throwOnFailure()
}
val podcastEntries = response.body()!!.podcastChannels[0].episodeList val podcastEntries = response.body()!!.podcastChannels[0].episodeList
val musicDirectory = MusicDirectory() val musicDirectory = MusicDirectory()
@ -384,9 +382,8 @@ open class RESTMusicService(
artist: String, artist: String,
title: String title: String
): Lyrics { ): Lyrics {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getLyrics(artist, title).execute()
api.getLyrics(artist, title).execute() response.throwOnFailure()
}
return response.body()!!.lyrics.toDomainEntity() return response.body()!!.lyrics.toDomainEntity()
} }
@ -396,9 +393,7 @@ open class RESTMusicService(
id: String, id: String,
submission: Boolean submission: Boolean
) { ) {
responseChecker.callWithResponseCheck { api -> API.scrobble(id, null, submission).execute().throwOnFailure()
api.scrobble(id, null, submission).execute()
}
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -408,10 +403,17 @@ open class RESTMusicService(
offset: Int, offset: Int,
musicFolderId: String? musicFolderId: String?
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getAlbumList(
api.getAlbumList(fromName(type), size, offset, null, null, null, musicFolderId) fromName(type),
.execute() size,
} offset,
null,
null,
null,
musicFolderId
).execute()
response.throwOnFailure()
val childList = response.body()!!.albumList.toDomainEntityList() val childList = response.body()!!.albumList.toDomainEntityList()
val result = MusicDirectory() val result = MusicDirectory()
@ -427,17 +429,17 @@ open class RESTMusicService(
offset: Int, offset: Int,
musicFolderId: String? musicFolderId: String?
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getAlbumList2(
api.getAlbumList2( fromName(type),
fromName(type), size,
size, offset,
offset, null,
null, null,
null, null,
null, musicFolderId
musicFolderId ).execute()
).execute()
} response.throwOnFailure()
val result = MusicDirectory() val result = MusicDirectory()
result.addAll(response.body()!!.albumList.toDomainEntityList()) result.addAll(response.body()!!.albumList.toDomainEntityList())
@ -449,15 +451,15 @@ open class RESTMusicService(
override fun getRandomSongs( override fun getRandomSongs(
size: Int size: Int
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getRandomSongs(
api.getRandomSongs( size,
size, null,
null, null,
null, null,
null, null
null ).execute()
).execute()
} response.throwOnFailure()
val result = MusicDirectory() val result = MusicDirectory()
result.addAll(response.body()!!.songsList.toDomainEntityList()) result.addAll(response.body()!!.songsList.toDomainEntityList())
@ -467,18 +469,18 @@ open class RESTMusicService(
@Throws(Exception::class) @Throws(Exception::class)
override fun getStarred(): SearchResult { override fun getStarred(): SearchResult {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getStarred(null).execute()
api.getStarred(null).execute()
} response.throwOnFailure()
return response.body()!!.starred.toDomainEntity() return response.body()!!.starred.toDomainEntity()
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getStarred2(): SearchResult { override fun getStarred2(): SearchResult {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getStarred2(null).execute()
api.getStarred2(null).execute()
} response.throwOnFailure()
return response.body()!!.starred2.toDomainEntity() return response.body()!!.starred2.toDomainEntity()
} }
@ -491,8 +493,10 @@ open class RESTMusicService(
): Pair<InputStream, Boolean> { ): Pair<InputStream, Boolean> {
val songOffset = if (offset < 0) 0 else offset val songOffset = if (offset < 0) 0 else offset
val response = subsonicAPIClient.stream(song.id, maxBitrate, songOffset) val response = API.stream(song.id, maxBitrate, offset = songOffset)
checkStreamResponseError(response) .execute().toStreamResponse()
response.throwOnFailure()
if (response.stream == null) { if (response.stream == null) {
throw IOException("Null stream response") throw IOException("Null stream response")
@ -518,13 +522,18 @@ open class RESTMusicService(
Thread( Thread(
{ {
expectedResult[0] = subsonicAPIClient.getStreamUrl(id) + "&format=raw" expectedResult[0] = API.getStreamUrl(id)
latch.countDown() latch.countDown()
}, },
"Get-Video-Url" "Get-Video-Url"
).start() ).start()
latch.await(5, TimeUnit.SECONDS) // Getting the stream can take a long time on some servers
latch.await(1, TimeUnit.MINUTES)
if (expectedResult[0] == null) {
throw TimeoutException("Server didn't respond in time")
}
return expectedResult[0]!! return expectedResult[0]!!
} }
@ -533,10 +542,10 @@ open class RESTMusicService(
override fun updateJukeboxPlaylist( override fun updateJukeboxPlaylist(
ids: List<String>? ids: List<String>?
): JukeboxStatus { ): JukeboxStatus {
val response = responseChecker.callWithResponseCheck { api -> val response = API.jukeboxControl(JukeboxAction.SET, null, null, ids, null)
api.jukeboxControl(JukeboxAction.SET, null, null, ids, null) .execute()
.execute()
} response.throwOnFailure()
return response.body()!!.jukebox.toDomainEntity() return response.body()!!.jukebox.toDomainEntity()
} }
@ -546,40 +555,40 @@ open class RESTMusicService(
index: Int, index: Int,
offsetSeconds: Int offsetSeconds: Int
): JukeboxStatus { ): JukeboxStatus {
val response = responseChecker.callWithResponseCheck { api -> val response = API.jukeboxControl(JukeboxAction.SKIP, index, offsetSeconds, null, null)
api.jukeboxControl(JukeboxAction.SKIP, index, offsetSeconds, null, null) .execute()
.execute()
} response.throwOnFailure()
return response.body()!!.jukebox.toDomainEntity() return response.body()!!.jukebox.toDomainEntity()
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun stopJukebox(): JukeboxStatus { override fun stopJukebox(): JukeboxStatus {
val response = responseChecker.callWithResponseCheck { api -> val response = API.jukeboxControl(JukeboxAction.STOP, null, null, null, null)
api.jukeboxControl(JukeboxAction.STOP, null, null, null, null) .execute()
.execute()
} response.throwOnFailure()
return response.body()!!.jukebox.toDomainEntity() return response.body()!!.jukebox.toDomainEntity()
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun startJukebox(): JukeboxStatus { override fun startJukebox(): JukeboxStatus {
val response = responseChecker.callWithResponseCheck { api -> val response = API.jukeboxControl(JukeboxAction.START, null, null, null, null)
api.jukeboxControl(JukeboxAction.START, null, null, null, null) .execute()
.execute()
} response.throwOnFailure()
return response.body()!!.jukebox.toDomainEntity() return response.body()!!.jukebox.toDomainEntity()
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getJukeboxStatus(): JukeboxStatus { override fun getJukeboxStatus(): JukeboxStatus {
val response = responseChecker.callWithResponseCheck { api -> val response = API.jukeboxControl(JukeboxAction.STATUS, null, null, null, null)
api.jukeboxControl(JukeboxAction.STATUS, null, null, null, null) .execute()
.execute()
} response.throwOnFailure()
return response.body()!!.jukebox.toDomainEntity() return response.body()!!.jukebox.toDomainEntity()
} }
@ -588,10 +597,10 @@ open class RESTMusicService(
override fun setJukeboxGain( override fun setJukeboxGain(
gain: Float gain: Float
): JukeboxStatus { ): JukeboxStatus {
val response = responseChecker.callWithResponseCheck { api -> val response = API.jukeboxControl(JukeboxAction.SET_GAIN, null, null, null, gain)
api.jukeboxControl(JukeboxAction.SET_GAIN, null, null, null, gain) .execute()
.execute()
} response.throwOnFailure()
return response.body()!!.jukebox.toDomainEntity() return response.body()!!.jukebox.toDomainEntity()
} }
@ -600,7 +609,8 @@ open class RESTMusicService(
override fun getShares( override fun getShares(
refresh: Boolean refresh: Boolean
): List<Share> { ): List<Share> {
val response = responseChecker.callWithResponseCheck { api -> api.getShares().execute() } val response = API.getShares().execute()
response.throwOnFailure()
return response.body()!!.shares.toDomainEntitiesList() return response.body()!!.shares.toDomainEntitiesList()
} }
@ -609,7 +619,8 @@ open class RESTMusicService(
override fun getGenres( override fun getGenres(
refresh: Boolean refresh: Boolean
): List<Genre>? { ): List<Genre>? {
val response = responseChecker.callWithResponseCheck { api -> api.getGenres().execute() } val response = API.getGenres().execute()
response.throwOnFailure()
return response.body()!!.genresList.toDomainEntityList() return response.body()!!.genresList.toDomainEntityList()
} }
@ -620,9 +631,8 @@ open class RESTMusicService(
count: Int, count: Int,
offset: Int offset: Int
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getSongsByGenre(genre, count, offset, null).execute()
api.getSongsByGenre(genre, count, offset, null).execute() response.throwOnFailure()
}
val result = MusicDirectory() val result = MusicDirectory()
result.addAll(response.body()!!.songsList.toDomainEntityList()) result.addAll(response.body()!!.songsList.toDomainEntityList())
@ -634,9 +644,9 @@ open class RESTMusicService(
override fun getUser( override fun getUser(
username: String username: String
): UserInfo { ): UserInfo {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getUser(username).execute()
api.getUser(username).execute()
} response.throwOnFailure()
return response.body()!!.user.toDomainEntity() return response.body()!!.user.toDomainEntity()
} }
@ -645,9 +655,9 @@ open class RESTMusicService(
override fun getChatMessages( override fun getChatMessages(
since: Long? since: Long?
): List<ChatMessage> { ): List<ChatMessage> {
val response = responseChecker.callWithResponseCheck { api -> val response = API.getChatMessages(since).execute()
api.getChatMessages(since).execute()
} response.throwOnFailure()
return response.body()!!.chatMessages.toDomainEntitiesList() return response.body()!!.chatMessages.toDomainEntitiesList()
} }
@ -656,12 +666,13 @@ open class RESTMusicService(
override fun addChatMessage( override fun addChatMessage(
message: String message: String
) { ) {
responseChecker.callWithResponseCheck { api -> api.addChatMessage(message).execute() } API.addChatMessage(message).execute().throwOnFailure()
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getBookmarks(): List<Bookmark> { override fun getBookmarks(): List<Bookmark> {
val response = responseChecker.callWithResponseCheck { api -> api.getBookmarks().execute() } val response = API.getBookmarks().execute()
response.throwOnFailure()
return response.body()!!.bookmarkList.toDomainEntitiesList() return response.body()!!.bookmarkList.toDomainEntitiesList()
} }
@ -671,23 +682,22 @@ open class RESTMusicService(
id: String, id: String,
position: Int position: Int
) { ) {
responseChecker.callWithResponseCheck { api -> API.createBookmark(id, position.toLong(), null).execute().throwOnFailure()
api.createBookmark(id, position.toLong(), null).execute()
}
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun deleteBookmark( override fun deleteBookmark(
id: String id: String
) { ) {
responseChecker.callWithResponseCheck { api -> api.deleteBookmark(id).execute() } API.deleteBookmark(id).execute().throwOnFailure()
} }
@Throws(Exception::class) @Throws(Exception::class)
override fun getVideos( override fun getVideos(
refresh: Boolean refresh: Boolean
): MusicDirectory { ): MusicDirectory {
val response = responseChecker.callWithResponseCheck { api -> api.getVideos().execute() } val response = API.getVideos().execute()
response.throwOnFailure()
val musicDirectory = MusicDirectory() val musicDirectory = MusicDirectory()
musicDirectory.addAll(response.body()!!.videosList.toDomainEntityList()) musicDirectory.addAll(response.body()!!.videosList.toDomainEntityList())
@ -701,9 +711,8 @@ open class RESTMusicService(
description: String?, description: String?,
expires: Long? expires: Long?
): List<Share> { ): List<Share> {
val response = responseChecker.callWithResponseCheck { api -> val response = API.createShare(ids, description, expires).execute()
api.createShare(ids, description, expires).execute() response.throwOnFailure()
}
return response.body()!!.shares.toDomainEntitiesList() return response.body()!!.shares.toDomainEntitiesList()
} }
@ -712,7 +721,7 @@ open class RESTMusicService(
override fun deleteShare( override fun deleteShare(
id: String id: String
) { ) {
responseChecker.callWithResponseCheck { api -> api.deleteShare(id).execute() } API.deleteShare(id).execute().throwOnFailure()
} }
@Throws(Exception::class) @Throws(Exception::class)
@ -726,8 +735,15 @@ open class RESTMusicService(
expiresValue = null expiresValue = null
} }
responseChecker.callWithResponseCheck { api -> API.updateShare(id, description, expiresValue).execute().throwOnFailure()
api.updateShare(id, description, expiresValue).execute() }
init {
// The client will notice if the minimum supported API version has changed
// By registering a callback we ensure this info is saved in the database as well
subsonicAPIClient.onProtocolChange = {
Timber.i("Server minimum API version set to %s", it)
activeServerProvider.setMinimumApiVersion(it.toString())
} }
} }
@ -735,19 +751,5 @@ open class RESTMusicService(
private const val MUSIC_FOLDER_STORAGE_NAME = "music_folder" private const val MUSIC_FOLDER_STORAGE_NAME = "music_folder"
private const val INDEXES_STORAGE_NAME = "indexes" private const val INDEXES_STORAGE_NAME = "indexes"
private const val ARTISTS_STORAGE_NAME = "artists" private const val ARTISTS_STORAGE_NAME = "artists"
// TODO: Move to response checker
@Throws(SubsonicRESTException::class, IOException::class)
fun checkStreamResponseError(response: StreamResponse) {
if (response.hasError() || response.stream == null) {
if (response.apiError != null) {
throw SubsonicRESTException(response.apiError!!)
} else {
throw IOException(
"Failed to make endpoint request, code: " + response.responseHttpCode
)
}
}
}
} }
} }

View File

@ -12,7 +12,7 @@ import org.moire.ultrasonic.api.subsonic.SubsonicError.TokenAuthNotSupportedForL
import org.moire.ultrasonic.api.subsonic.SubsonicError.TrialPeriodIsOver import org.moire.ultrasonic.api.subsonic.SubsonicError.TrialPeriodIsOver
import org.moire.ultrasonic.api.subsonic.SubsonicError.UserNotAuthorizedForOperation import org.moire.ultrasonic.api.subsonic.SubsonicError.UserNotAuthorizedForOperation
import org.moire.ultrasonic.api.subsonic.SubsonicError.WrongUsernameOrPassword import org.moire.ultrasonic.api.subsonic.SubsonicError.WrongUsernameOrPassword
import org.moire.ultrasonic.service.SubsonicRESTException import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
/** /**
* Extension for [SubsonicRESTException] that returns localized error string, that can used to * Extension for [SubsonicRESTException] that returns localized error string, that can used to
@ -21,7 +21,7 @@ import org.moire.ultrasonic.service.SubsonicRESTException
fun SubsonicRESTException.getLocalizedErrorMessage(context: Context): String = fun SubsonicRESTException.getLocalizedErrorMessage(context: Context): String =
when (error) { when (error) {
is Generic -> { is Generic -> {
val message = error.message val message = (error as Generic).message
val errorMessage = if (message == "") { val errorMessage = if (message == "") {
context.getString(R.string.api_subsonic_generic_no_message) context.getString(R.string.api_subsonic_generic_no_message)
} else { } else {

View File

@ -14,6 +14,7 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.response.StreamResponse import org.moire.ultrasonic.api.subsonic.response.StreamResponse
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -59,7 +60,7 @@ class AvatarRequestHandlerTest {
apiError = null, apiError = null,
responseHttpCode = 200 responseHttpCode = 200
) )
whenever(mockApiClient.getAvatar(any())) whenever(mockApiClient.api.getAvatar(any()).execute().toStreamResponse())
.thenReturn(streamResponse) .thenReturn(streamResponse)
val response = handler.load( val response = handler.load(

View File

@ -16,6 +16,7 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.response.StreamResponse import org.moire.ultrasonic.api.subsonic.response.StreamResponse
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@ -56,7 +57,9 @@ class CoverArtRequestHandlerTest {
fun `Should throw IOException when request to api failed`() { fun `Should throw IOException when request to api failed`() {
val streamResponse = StreamResponse(null, null, 500) val streamResponse = StreamResponse(null, null, 500)
whenever(mockApiClient.getCoverArt(any(), anyOrNull())).thenReturn(streamResponse) whenever(
mockApiClient.api.getCoverArt(any(), anyOrNull()).execute().toStreamResponse()
).thenReturn(streamResponse)
val fail = { val fail = {
handler.load(createLoadCoverArtRequest("some").buildRequest(), 0) handler.load(createLoadCoverArtRequest("some").buildRequest(), 0)
@ -73,7 +76,9 @@ class CoverArtRequestHandlerTest {
responseHttpCode = 200 responseHttpCode = 200
) )
whenever(mockApiClient.getCoverArt(any(), anyOrNull())).thenReturn(streamResponse) whenever(
mockApiClient.api.getCoverArt(any(), anyOrNull()).execute().toStreamResponse()
).thenReturn(streamResponse)
val response = handler.load( val response = handler.load(
createLoadCoverArtRequest("some").buildRequest(), 0 createLoadCoverArtRequest("some").buildRequest(), 0