2017-07-23 19:46:06 +02:00
|
|
|
package org.moire.ultrasonic.api.subsonic
|
|
|
|
|
|
|
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper
|
|
|
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
2017-10-17 21:31:33 +02:00
|
|
|
import com.fasterxml.jackson.module.kotlin.readValue
|
2017-07-23 19:46:06 +02:00
|
|
|
import okhttp3.OkHttpClient
|
2017-10-17 21:31:33 +02:00
|
|
|
import okhttp3.ResponseBody
|
2017-07-23 19:46:06 +02:00
|
|
|
import okhttp3.logging.HttpLoggingInterceptor
|
2017-11-22 21:56:16 +01:00
|
|
|
import org.moire.ultrasonic.api.subsonic.interceptors.PasswordHexInterceptor
|
|
|
|
import org.moire.ultrasonic.api.subsonic.interceptors.PasswordMD5Interceptor
|
2017-11-05 18:44:31 +01:00
|
|
|
import org.moire.ultrasonic.api.subsonic.interceptors.RangeHeaderInterceptor
|
2017-10-17 21:31:33 +02:00
|
|
|
import org.moire.ultrasonic.api.subsonic.response.StreamResponse
|
|
|
|
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
|
|
|
|
import retrofit2.Response
|
2017-07-23 19:46:06 +02:00
|
|
|
import retrofit2.Retrofit
|
|
|
|
import retrofit2.converter.jackson.JacksonConverterFactory
|
2017-11-14 22:11:03 +01:00
|
|
|
import java.util.concurrent.TimeUnit.MILLISECONDS
|
|
|
|
|
|
|
|
private const val READ_TIMEOUT = 60_000L
|
2017-07-23 19:46:06 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Subsonic API client that provides api access.
|
|
|
|
*
|
|
|
|
* For supported API calls see [SubsonicAPIDefinition].
|
|
|
|
*
|
|
|
|
* @author Yahor Berdnikau
|
|
|
|
*/
|
|
|
|
class SubsonicAPIClient(baseUrl: String,
|
|
|
|
username: String,
|
|
|
|
private val password: String,
|
|
|
|
clientProtocolVersion: SubsonicAPIVersions,
|
|
|
|
clientID: String,
|
|
|
|
debug: Boolean = false) {
|
|
|
|
private val okHttpClient = OkHttpClient.Builder()
|
2017-11-14 22:11:03 +01:00
|
|
|
.readTimeout(READ_TIMEOUT, MILLISECONDS)
|
2017-07-23 19:46:06 +02:00
|
|
|
.addInterceptor { chain ->
|
|
|
|
// Adds default request params
|
|
|
|
val originalRequest = chain.request()
|
|
|
|
val newUrl = originalRequest.url().newBuilder()
|
|
|
|
.addQueryParameter("u", username)
|
|
|
|
.addQueryParameter("v", clientProtocolVersion.restApiVersion)
|
|
|
|
.addQueryParameter("c", clientID)
|
|
|
|
.addQueryParameter("f", "json")
|
|
|
|
.build()
|
|
|
|
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
|
|
|
}
|
2017-11-05 18:44:31 +01:00
|
|
|
.addInterceptor(RangeHeaderInterceptor())
|
2017-11-22 21:56:16 +01:00
|
|
|
.apply { if (debug) addLogging() }
|
|
|
|
.addPasswordQueryParam(clientProtocolVersion)
|
|
|
|
.build()
|
2017-07-23 19:46:06 +02:00
|
|
|
|
|
|
|
private val jacksonMapper = ObjectMapper()
|
|
|
|
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
|
2017-09-14 21:51:56 +02:00
|
|
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
2017-07-23 19:46:06 +02:00
|
|
|
.registerModule(KotlinModule())
|
|
|
|
|
|
|
|
private val retrofit = Retrofit.Builder()
|
2017-07-24 22:35:25 +02:00
|
|
|
.baseUrl("$baseUrl/rest/")
|
2017-07-23 19:46:06 +02:00
|
|
|
.client(okHttpClient)
|
|
|
|
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
|
|
|
|
.build()
|
|
|
|
|
|
|
|
val api: SubsonicAPIDefinition = retrofit.create(SubsonicAPIDefinition::class.java)
|
|
|
|
|
2017-10-17 21:31:33 +02:00
|
|
|
/**
|
|
|
|
* 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()
|
|
|
|
}
|
|
|
|
|
2017-11-05 22:14:02 +01:00
|
|
|
/**
|
|
|
|
* 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()
|
|
|
|
}
|
|
|
|
|
2017-11-19 21:19:33 +01:00
|
|
|
/**
|
|
|
|
* 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()
|
|
|
|
}
|
|
|
|
|
2017-10-17 21:31:33 +02:00
|
|
|
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())
|
2017-11-05 22:14:02 +01:00
|
|
|
StreamResponse(apiError = error.error, responseHttpCode = response.code())
|
2017-10-17 21:31:33 +02:00
|
|
|
} else {
|
2017-11-05 22:14:02 +01:00
|
|
|
StreamResponse(stream = responseBody.byteStream(),
|
|
|
|
responseHttpCode = response.code())
|
2017-10-17 21:31:33 +02:00
|
|
|
}
|
|
|
|
} else {
|
2017-11-05 22:14:02 +01:00
|
|
|
StreamResponse(responseHttpCode = response.code())
|
2017-10-17 21:31:33 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-11-21 20:34:53 +01:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
}
|
|
|
|
|
2017-07-23 19:46:06 +02:00
|
|
|
private fun OkHttpClient.Builder.addLogging() {
|
|
|
|
val loggingInterceptor = HttpLoggingInterceptor()
|
2017-08-11 20:48:55 +02:00
|
|
|
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
2017-07-23 19:46:06 +02:00
|
|
|
this.addInterceptor(loggingInterceptor)
|
|
|
|
}
|
|
|
|
|
2017-11-22 21:56:16 +01:00
|
|
|
private fun OkHttpClient.Builder.addPasswordQueryParam(
|
|
|
|
clientProtocolVersion: SubsonicAPIVersions): OkHttpClient.Builder {
|
2017-07-23 19:46:06 +02:00
|
|
|
if (clientProtocolVersion < SubsonicAPIVersions.V1_13_0) {
|
2017-11-22 21:56:16 +01:00
|
|
|
this.addInterceptor(PasswordHexInterceptor(password))
|
2017-07-23 19:46:06 +02:00
|
|
|
} else {
|
2017-11-22 21:56:16 +01:00
|
|
|
this.addInterceptor(PasswordMD5Interceptor(password))
|
2017-07-23 19:46:06 +02:00
|
|
|
}
|
2017-11-22 21:56:16 +01:00
|
|
|
return this
|
2017-07-23 19:46:06 +02:00
|
|
|
}
|
2017-07-30 22:23:20 +02:00
|
|
|
}
|