Add using new authentication method since 1.13.0.
Signed-off-by: Yahor Berdnikau <egorr.berd@gmail.com>
This commit is contained in:
parent
20d95ce19d
commit
f1ab0a3e0c
|
@ -12,11 +12,12 @@ ext.versions = [
|
|||
|
||||
retrofit : "2.1.0",
|
||||
jackson : "2.8.7",
|
||||
okhttp : "3.6.0",
|
||||
|
||||
junit : "4.12",
|
||||
mockitoKotlin : "1.3.0",
|
||||
kluent : "1.15",
|
||||
okhttp : "3.6.0",
|
||||
mockitoKotlin : "1.5.0",
|
||||
kluent : "1.26",
|
||||
apacheCodecs : "1.10",
|
||||
]
|
||||
|
||||
ext.gradlePlugins = [
|
||||
|
@ -30,7 +31,7 @@ ext.androidSupport = [
|
|||
]
|
||||
|
||||
ext.other = [
|
||||
kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib-common:$versions.kotlin",
|
||||
kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin",
|
||||
retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit",
|
||||
gsonConverter : "com.squareup.retrofit2:converter-gson:$versions.retrofit",
|
||||
jacksonConverter : "com.squareup.retrofit2:converter-jackson:$versions.retrofit",
|
||||
|
@ -41,7 +42,9 @@ ext.other = [
|
|||
ext.testing = [
|
||||
junit : "junit:junit:$versions.junit",
|
||||
kotlinJunit : "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin",
|
||||
kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin",
|
||||
mockitoKotlin : "com.nhaarman:mockito-kotlin:$versions.mockitoKotlin",
|
||||
kluent : "org.amshove.kluent:kluent:$versions.kluent",
|
||||
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp"
|
||||
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp",
|
||||
apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs",
|
||||
]
|
||||
|
|
|
@ -16,7 +16,9 @@ dependencies {
|
|||
|
||||
testCompile testing.junit
|
||||
testCompile testing.kotlinJunit
|
||||
testCompile testing.kotlinReflect
|
||||
testCompile testing.mockitoKotlin
|
||||
testCompile testing.kluent
|
||||
testCompile testing.mockWebServer
|
||||
testCompile testing.apacheCodecs
|
||||
}
|
|
@ -6,21 +6,28 @@ import org.amshove.kluent.`should be`
|
|||
import org.amshove.kluent.`should contain`
|
||||
import org.amshove.kluent.`should equal`
|
||||
import org.amshove.kluent.`should not be`
|
||||
import org.amshove.kluent.`should not contain`
|
||||
import org.apache.commons.codec.binary.Hex
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.moire.ultrasonic.api.subsonic.models.*
|
||||
import org.moire.ultrasonic.api.subsonic.models.Artist
|
||||
import org.moire.ultrasonic.api.subsonic.models.Index
|
||||
import org.moire.ultrasonic.api.subsonic.models.License
|
||||
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
|
||||
import org.moire.ultrasonic.api.subsonic.models.MusicFolder
|
||||
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
|
||||
import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
|
||||
import retrofit2.Response
|
||||
import java.nio.charset.Charset
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Integration test for [SubsonicAPI] class.
|
||||
* Integration test for [SubsonicAPIClient] class.
|
||||
*/
|
||||
class SubsonicAPITest {
|
||||
class SubsonicAPIClientTest {
|
||||
companion object {
|
||||
const val USERNAME = "some-user"
|
||||
const val PASSWORD = "some-password"
|
||||
|
@ -30,19 +37,58 @@ class SubsonicAPITest {
|
|||
|
||||
@JvmField @Rule val mockWebServerRule = MockWebServerRule()
|
||||
|
||||
private lateinit var api: SubsonicAPI
|
||||
private lateinit var client: SubsonicAPIClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
api = SubsonicAPI(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD,
|
||||
client = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD,
|
||||
CLIENT_VERSION, CLIENT_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Should pass password hash and salt in query params for api version 1_13_0`() {
|
||||
val clientV12 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME,
|
||||
PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID)
|
||||
enqueueResponse("ping_ok.json")
|
||||
|
||||
clientV12.api.ping().execute()
|
||||
|
||||
with(mockWebServerRule.mockWebServer.takeRequest()) {
|
||||
requestLine `should contain` "&s="
|
||||
requestLine `should contain` "&t="
|
||||
requestLine `should not contain` "&p=enc:"
|
||||
|
||||
val salt = requestLine.split('&').find { it.startsWith("s=") }?.substringAfter('=')
|
||||
val token = requestLine.split('&').find { it.startsWith("t=") }?.substringAfter('=')
|
||||
val expectedToken = String(Hex.encodeHex(MessageDigest.getInstance("MD5")
|
||||
.digest("$PASSWORD$salt".toByteArray()), false))
|
||||
token!! `should equal` expectedToken
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Should pass hex encoded password in query params for api version 1_12_0`() {
|
||||
val clientV11 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME,
|
||||
PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID)
|
||||
enqueueResponse("ping_ok.json")
|
||||
|
||||
clientV11.api.ping().execute()
|
||||
|
||||
with(mockWebServerRule.mockWebServer.takeRequest()) {
|
||||
requestLine `should not contain` "&s="
|
||||
requestLine `should not contain` "&t="
|
||||
requestLine `should contain` "&p=enc:"
|
||||
val passParam = requestLine.split('&').find { it.startsWith("p=enc:") }
|
||||
val encodedPassword = String(Hex.encodeHex(PASSWORD.toByteArray(), false))
|
||||
passParam `should equal` "p=enc:$encodedPassword"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Should parse ping ok response`() {
|
||||
enqueueResponse("ping_ok.json")
|
||||
|
||||
val response = api.getApi().ping().execute()
|
||||
val response = client.api.ping().execute()
|
||||
|
||||
assertResponseSuccessful(response)
|
||||
with(response.body()) {
|
||||
|
@ -52,14 +98,14 @@ class SubsonicAPITest {
|
|||
|
||||
@Test
|
||||
fun `Should parse ping error response`() {
|
||||
checkErrorCallParsed { api.getApi().ping().execute() }
|
||||
checkErrorCallParsed { client.api.ping().execute() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Should parse get license ok response`() {
|
||||
enqueueResponse("license_ok.json")
|
||||
|
||||
val response = api.getApi().getLicense().execute()
|
||||
val response = client.api.getLicense().execute()
|
||||
|
||||
assertResponseSuccessful(response)
|
||||
with(response.body()) {
|
||||
|
@ -70,7 +116,7 @@ class SubsonicAPITest {
|
|||
|
||||
@Test
|
||||
fun `Should parse get license error response`() {
|
||||
val response = checkErrorCallParsed { api.getApi().getLicense().execute() }
|
||||
val response = checkErrorCallParsed { client.api.getLicense().execute() }
|
||||
|
||||
response.license `should be` null
|
||||
}
|
||||
|
@ -79,7 +125,7 @@ class SubsonicAPITest {
|
|||
fun `Should parse get music folders ok response`() {
|
||||
enqueueResponse("get_music_folders_ok.json")
|
||||
|
||||
val response = api.getApi().getMusicFolders().execute()
|
||||
val response = client.api.getMusicFolders().execute()
|
||||
|
||||
assertResponseSuccessful(response)
|
||||
with(response.body()) {
|
||||
|
@ -90,7 +136,7 @@ class SubsonicAPITest {
|
|||
|
||||
@Test
|
||||
fun `Should parse get music folders error response`() {
|
||||
val response = checkErrorCallParsed { api.getApi().getMusicFolders().execute() }
|
||||
val response = checkErrorCallParsed { client.api.getMusicFolders().execute() }
|
||||
|
||||
response.musicFolders `should be` null
|
||||
}
|
||||
|
@ -100,7 +146,7 @@ class SubsonicAPITest {
|
|||
// TODO: check for shortcut parsing
|
||||
enqueueResponse("get_indexes_ok.json")
|
||||
|
||||
val response = api.getApi().getIndexes(null, null).execute()
|
||||
val response = client.api.getIndexes(null, null).execute()
|
||||
|
||||
assertResponseSuccessful(response)
|
||||
response.body().indexes `should not be` null
|
||||
|
@ -126,7 +172,7 @@ class SubsonicAPITest {
|
|||
enqueueResponse("get_indexes_ok.json")
|
||||
val musicFolderId = 9L
|
||||
|
||||
api.getApi().getIndexes(musicFolderId, null).execute()
|
||||
client.api.getIndexes(musicFolderId, null).execute()
|
||||
|
||||
with(mockWebServerRule.mockWebServer.takeRequest()) {
|
||||
requestLine `should contain` "musicFolderId=$musicFolderId"
|
||||
|
@ -138,7 +184,7 @@ class SubsonicAPITest {
|
|||
enqueueResponse("get_indexes_ok.json")
|
||||
val ifModifiedSince = System.currentTimeMillis()
|
||||
|
||||
api.getApi().getIndexes(null, ifModifiedSince).execute()
|
||||
client.api.getIndexes(null, ifModifiedSince).execute()
|
||||
|
||||
with(mockWebServerRule.mockWebServer.takeRequest()) {
|
||||
requestLine `should contain` "ifModifiedSince=$ifModifiedSince"
|
||||
|
@ -151,7 +197,7 @@ class SubsonicAPITest {
|
|||
val musicFolderId = 110L
|
||||
val ifModifiedSince = System.currentTimeMillis()
|
||||
|
||||
api.getApi().getIndexes(musicFolderId, ifModifiedSince).execute()
|
||||
client.api.getIndexes(musicFolderId, ifModifiedSince).execute()
|
||||
|
||||
with(mockWebServerRule.mockWebServer.takeRequest()) {
|
||||
requestLine `should contain` "musicFolderId=$musicFolderId"
|
||||
|
@ -161,14 +207,14 @@ class SubsonicAPITest {
|
|||
|
||||
@Test
|
||||
fun `Should parse get indexes error response`() {
|
||||
val response = checkErrorCallParsed { api.getApi().getIndexes(null, null).execute() }
|
||||
val response = checkErrorCallParsed { client.api.getIndexes(null, null).execute() }
|
||||
|
||||
response.indexes `should be` null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Should parse getMusicDirectory error response`() {
|
||||
val response = checkErrorCallParsed { api.getApi().getMusicDirectory(1).execute() }
|
||||
val response = checkErrorCallParsed { client.api.getMusicDirectory(1).execute() }
|
||||
|
||||
response.musicDirectory `should be` null
|
||||
}
|
||||
|
@ -178,7 +224,7 @@ class SubsonicAPITest {
|
|||
enqueueResponse("get_music_directory_ok.json")
|
||||
val directoryId = 124L
|
||||
|
||||
api.getApi().getMusicDirectory(directoryId).execute()
|
||||
client.api.getMusicDirectory(directoryId).execute()
|
||||
|
||||
mockWebServerRule.mockWebServer.takeRequest().requestLine `should contain` "id=$directoryId"
|
||||
}
|
||||
|
@ -187,7 +233,7 @@ class SubsonicAPITest {
|
|||
fun `Should parse get music directory ok response`() {
|
||||
enqueueResponse("get_music_directory_ok.json")
|
||||
|
||||
val response = api.getApi().getMusicDirectory(1).execute()
|
||||
val response = client.api.getMusicDirectory(1).execute()
|
||||
|
||||
assertResponseSuccessful(response)
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.jackson.JacksonConverterFactory
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Main entry for Subsonic API calls.
|
||||
*
|
||||
* @see SubsonicAPI http://www.subsonic.org/pages/api.jsp
|
||||
*/
|
||||
class SubsonicAPI(baseUrl: String,
|
||||
username: String,
|
||||
private val password: String,
|
||||
clientProtocolVersion: SubsonicAPIVersions,
|
||||
clientID: String,
|
||||
debug: Boolean = false) {
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
// Adds default request params
|
||||
val originalRequest = chain.request()
|
||||
val newUrl = originalRequest.url().newBuilder()
|
||||
.addQueryParameter("u", username)
|
||||
.addQueryParameter("p", passwordHex())
|
||||
.addQueryParameter("v", clientProtocolVersion.restApiVersion)
|
||||
.addQueryParameter("c", clientID)
|
||||
.addQueryParameter("f", "json")
|
||||
.build()
|
||||
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||
}
|
||||
.apply {
|
||||
if (debug) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor()
|
||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BASIC
|
||||
this.addInterceptor(loggingInterceptor)
|
||||
}
|
||||
}.build()
|
||||
|
||||
private val jacksonMapper = ObjectMapper()
|
||||
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
|
||||
.registerModule(KotlinModule())
|
||||
|
||||
private val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
|
||||
.build()
|
||||
|
||||
private val subsonicAPI = retrofit.create(SubsonicAPIDefinition::class.java)
|
||||
|
||||
/**
|
||||
* Get API instance.
|
||||
*
|
||||
* @return initialized API instance
|
||||
*/
|
||||
fun getApi(): SubsonicAPIDefinition = subsonicAPI
|
||||
|
||||
private fun passwordHex() = "enc:${password.toHexBytes()}"
|
||||
|
||||
private fun String.toHexBytes(): String {
|
||||
return String.format("%040x", BigInteger(1, this.toByteArray()))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
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
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
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())
|
||||
}
|
||||
.also {
|
||||
if (debug) {
|
||||
it.addLogging()
|
||||
}
|
||||
}.build()
|
||||
|
||||
private val jacksonMapper = ObjectMapper()
|
||||
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
|
||||
.registerModule(KotlinModule())
|
||||
|
||||
private val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
|
||||
.build()
|
||||
|
||||
val api: SubsonicAPIDefinition = retrofit.create(SubsonicAPIDefinition::class.java)
|
||||
|
||||
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()
|
||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BASIC
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -40,8 +40,10 @@ dependencies {
|
|||
}
|
||||
testCompile(testing.mockitoKotlin) {
|
||||
exclude module: "kotlin-stdlib"
|
||||
exclude module: "kotlin-reflect"
|
||||
}
|
||||
testCompile(testing.kluent) {
|
||||
exclude module: "kotlin-stdlib"
|
||||
exclude module: "kotlin-reflect"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue