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.HttpUrl
|
|
|
|
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-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
|
|
|
|
import java.lang.IllegalStateException
|
|
|
|
import java.math.BigInteger
|
|
|
|
import java.security.MessageDigest
|
|
|
|
import java.security.NoSuchAlgorithmException
|
|
|
|
import java.security.SecureRandom
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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) {
|
|
|
|
companion object {
|
|
|
|
internal val HEX_ARRAY = "0123456789ABCDEF".toCharArray()
|
|
|
|
}
|
|
|
|
|
|
|
|
private val okHttpClient = OkHttpClient.Builder()
|
|
|
|
.addInterceptor { chain ->
|
|
|
|
// Adds default request params
|
|
|
|
val originalRequest = chain.request()
|
|
|
|
val newUrl = originalRequest.url().newBuilder()
|
|
|
|
.addQueryParameter("u", username)
|
|
|
|
.also {
|
|
|
|
it.addPasswordQueryParam(clientProtocolVersion)
|
|
|
|
}
|
|
|
|
.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-07-23 19:46:06 +02:00
|
|
|
.also {
|
|
|
|
if (debug) {
|
|
|
|
it.addLogging()
|
|
|
|
}
|
|
|
|
}.build()
|
|
|
|
|
|
|
|
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-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-07-23 19:46:06 +02:00
|
|
|
private val salt: String by lazy {
|
|
|
|
val secureRandom = SecureRandom()
|
|
|
|
BigInteger(130, secureRandom).toString(32)
|
|
|
|
}
|
|
|
|
|
|
|
|
private val passwordMD5Hash: String by lazy {
|
|
|
|
try {
|
|
|
|
val md5Digest = MessageDigest.getInstance("MD5")
|
|
|
|
md5Digest.digest("$password$salt".toByteArray()).toHexBytes()
|
|
|
|
} catch (e: NoSuchAlgorithmException) {
|
|
|
|
throw IllegalStateException(e)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private val passwordHex: String by lazy {
|
|
|
|
"enc:${password.toHexBytes()}"
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun String.toHexBytes(): String {
|
|
|
|
return this.toByteArray().toHexBytes()
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun ByteArray.toHexBytes(): String {
|
|
|
|
val hexChars = CharArray(this.size * 2)
|
|
|
|
for (j in 0..this.lastIndex) {
|
|
|
|
val v = this[j].toInt().and(0xFF)
|
|
|
|
hexChars[j * 2] = HEX_ARRAY[v.ushr(4)]
|
|
|
|
hexChars[j * 2 + 1] = HEX_ARRAY[v.and(0x0F)]
|
|
|
|
}
|
|
|
|
return String(hexChars)
|
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun HttpUrl.Builder.addPasswordQueryParam(clientProtocolVersion: SubsonicAPIVersions) {
|
|
|
|
if (clientProtocolVersion < SubsonicAPIVersions.V1_13_0) {
|
|
|
|
this.addQueryParameter("p", passwordHex)
|
|
|
|
} else {
|
|
|
|
this.addQueryParameter("t", passwordMD5Hash)
|
|
|
|
this.addQueryParameter("s", salt)
|
|
|
|
}
|
|
|
|
}
|
2017-07-30 22:23:20 +02:00
|
|
|
}
|