Updated Subsonic API version handling

This commit is contained in:
Nite 2020-08-27 10:27:25 +02:00
parent 0edaa29303
commit ca2bfbf14b
No known key found for this signature in database
GPG Key ID: 1D1AD59B1C6386C1
20 changed files with 221 additions and 146 deletions

View File

@ -14,7 +14,7 @@ abstract class SubsonicAPIClientTest {
protected lateinit var client: SubsonicAPIClient
@Before
fun setUp() {
open fun setUp() {
config = SubsonicClientConfiguration(
mockWebServerRule.mockWebServer.url("/").toString(),
USERNAME,

View File

@ -0,0 +1,54 @@
package org.moire.ultrasonic.api.subsonic
import com.fasterxml.jackson.databind.ObjectMapper
import okhttp3.mockwebserver.MockResponse
import org.amshove.kluent.`should be`
import org.junit.Before
import org.junit.Test
/**
* Integration test for [VersionAwareJacksonConverterFactory].
*/
class VersionAwareJacksonConverterFactoryTest : SubsonicAPIClientTest() {
private val initialProtocolVersion = SubsonicAPIVersions.V1_1_0
private var updatedProtocolVersion = SubsonicAPIVersions.V1_1_0
@Before
override fun setUp() {
config = SubsonicClientConfiguration(
mockWebServerRule.mockWebServer.url("/").toString(),
USERNAME,
PASSWORD,
initialProtocolVersion,
CLIENT_ID
)
client = SubsonicAPIClient(config)
}
@Test
fun `Should update version from response`() {
mockWebServerRule.enqueueResponse("ping_ok.json")
client.api.ping().execute()
client.protocolVersion.`should be`(SubsonicAPIVersions.V1_13_0)
}
@Test
fun `Should update version from response with utf-8 bom`() {
mockWebServerRule.enqueueResponse("ping_ok_utf8_bom.json")
client.api.ping().execute()
client.protocolVersion.`should be`(SubsonicAPIVersions.V1_16_0)
}
@Test
fun `Should not update version if response json doesn't contain version`() {
mockWebServerRule.enqueueResponse("non_subsonic_response.json")
client.api.stream("1234").execute()
client.protocolVersion.`should be`(initialProtocolVersion)
}
}

View File

@ -14,12 +14,9 @@ import org.moire.ultrasonic.api.subsonic.enqueueResponse
*/
class VersionInterceptorTest : BaseInterceptorTest() {
private val initialProtocolVersion = SubsonicAPIVersions.V1_1_0
private var updatedProtocolVersion = SubsonicAPIVersions.V1_1_0
override val interceptor: Interceptor by lazy(NONE) {
VersionInterceptor(initialProtocolVersion) {
updatedProtocolVersion = it
}
VersionInterceptor(initialProtocolVersion)
}
@Test
@ -33,55 +30,4 @@ class VersionInterceptorTest : BaseInterceptorTest() {
requestLine `should contain` "v=${initialProtocolVersion.restApiVersion}"
}
@Test
fun `Should update version from response`() {
mockWebServerRule.enqueueResponse("ping_ok.json")
client.newCall(createRequest {}).execute()
(interceptor as VersionInterceptor)
.protocolVersion `should equal` SubsonicAPIVersions.V1_13_0
}
@Test
fun `Should update version from response with utf-8 bom`() {
mockWebServerRule.enqueueResponse("ping_ok_utf8_bom.json")
client.newCall(createRequest {}).execute()
(interceptor as VersionInterceptor)
.protocolVersion `should equal` SubsonicAPIVersions.V1_16_0
}
@Test
fun `Should not update version if response json doesn't contain version`() {
mockWebServerRule.enqueueResponse("non_subsonic_response.json")
client.newCall(createRequest {}).execute()
(interceptor as VersionInterceptor).protocolVersion `should equal` initialProtocolVersion
}
@Test
fun `Should not update version on non-json response`() {
mockWebServerRule.mockWebServer.enqueue(
MockResponse()
.setBody("asdqwnekjnqwkjen")
.setHeader("Content-Type", "application/octet-stream")
)
client.newCall(createRequest {}).execute()
(interceptor as VersionInterceptor).protocolVersion `should equal` initialProtocolVersion
}
@Test
fun `Should notify notifier on version change`() {
mockWebServerRule.enqueueResponse("ping_ok.json")
client.newCall(createRequest {}).execute()
updatedProtocolVersion `should equal` SubsonicAPIVersions.V1_13_0
}
}

View File

@ -6,6 +6,11 @@ import java.io.IOException
* Special [IOException] to indicate that called api is not yet supported
* by current server api version.
*/
class ApiNotSupportedException(
serverApiVersion: SubsonicAPIVersions
) : IOException("Server api $serverApiVersion does not support this call")
class ApiNotSupportedException : IOException {
val serverApiVersion: String
constructor(
apiVersion: SubsonicAPIVersions
) : super("Server api $apiVersion does not support this call") {
serverApiVersion = apiVersion.restApiVersion
}
}

View File

@ -21,7 +21,6 @@ import org.moire.ultrasonic.api.subsonic.response.StreamResponse
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
private const val READ_TIMEOUT = 60_000L
@ -39,9 +38,7 @@ class SubsonicAPIClient(
config: SubsonicClientConfiguration,
baseOkClient: OkHttpClient = OkHttpClient.Builder().build()
) {
private val versionInterceptor = VersionInterceptor(config.minimalProtocolVersion) {
protocolVersion = it
}
private val versionInterceptor = VersionInterceptor(config.minimalProtocolVersion)
private val proxyPasswordInterceptor = ProxyPasswordInterceptor(
config.minimalProtocolVersion,
@ -58,6 +55,7 @@ class SubsonicAPIClient(
field = value
proxyPasswordInterceptor.apiVersion = field
wrappedApi.currentApiVersion = field
versionInterceptor.protocolVersion = field
}
private val okHttpClient = baseOkClient.newBuilder()
@ -87,7 +85,12 @@ class SubsonicAPIClient(
private val retrofit = Retrofit.Builder()
.baseUrl("${config.baseUrl}/rest/")
.client(okHttpClient)
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
.addConverterFactory(
VersionAwareJacksonConverterFactory.create(
{ protocolVersion = it },
jacksonMapper
)
)
.build()
private val wrappedApi = ApiVersionCheckWrapper(

View File

@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import java.lang.NumberFormatException
/**
* Subsonic REST API versions.
@ -31,28 +32,45 @@ enum class SubsonicAPIVersions(val subsonicVersions: String, val restApiVersion:
companion object {
@JvmStatic @Throws(IllegalArgumentException::class)
fun fromApiVersion(apiVersion: String): SubsonicAPIVersions {
when (apiVersion) {
"1.1.0" -> return V1_1_0
"1.1.1" -> return V1_1_1
"1.2.0" -> return V1_2_0
"1.3.0" -> return V1_3_0
"1.4.0" -> return V1_4_0
"1.5.0" -> return V1_5_0
"1.6.0" -> return V1_6_0
"1.7.0" -> return V1_7_0
"1.8.0" -> return V1_8_0
"1.9.0" -> return V1_9_0
"1.10.2" -> return V1_10_2
"1.10.5" -> return V1_10_2 // Non standard version of Madsonic Server 5.1
"1.11.0" -> return V1_11_0
"1.12.0" -> return V1_12_0
"1.13.0" -> return V1_13_0
"1.14.0" -> return V1_14_0
"1.15.0" -> return V1_15_0
"1.16.0" -> return V1_16_0
"1.16.1" -> return V1_16_0 // Fast and dirty fix to Subsonic 6.1.4
else -> throw IllegalArgumentException("Unknown api version $apiVersion")
fun getClosestKnownClientApiVersion(apiVersion: String): SubsonicAPIVersions {
val versionComponents = apiVersion.split(".")
if (versionComponents.size < 2)
throw IllegalArgumentException("Unknown api version $apiVersion")
try {
val majorVersion = versionComponents[0].toInt()
val minorVersion = versionComponents[1].toInt()
val patchVersion = if (versionComponents.size > 2) versionComponents[2].toInt()
else 0
when (majorVersion) {
1 -> when {
minorVersion < 1 ->
throw IllegalArgumentException("Unknown api version $apiVersion")
minorVersion < 2 && patchVersion < 1 -> return V1_1_0
minorVersion < 2 -> return V1_1_1
minorVersion < 3 -> return V1_2_0
minorVersion < 4 -> return V1_3_0
minorVersion < 5 -> return V1_4_0
minorVersion < 6 -> return V1_5_0
minorVersion < 7 -> return V1_6_0
minorVersion < 8 -> return V1_7_0
minorVersion < 9 -> return V1_8_0
minorVersion < 10 -> return V1_9_0
minorVersion < 11 -> return V1_10_2
minorVersion < 12 -> return V1_11_0
minorVersion < 13 -> return V1_12_0
minorVersion < 14 -> return V1_13_0
minorVersion < 15 -> return V1_14_0
minorVersion < 16 -> return V1_15_0
else -> return V1_16_0
}
// Subsonic API specifies that the client's and the server's major API version
// must be the same
else -> throw IllegalArgumentException("Unknown api version $apiVersion")
}
} catch (exception: NumberFormatException) {
throw IllegalArgumentException("Malformed api version $apiVersion")
}
}
@ -64,7 +82,7 @@ enum class SubsonicAPIVersions(val subsonicVersions: String, val restApiVersion:
if (p.currentName != "version") {
throw JsonParseException(p, "Not valid token for API version!")
}
return fromApiVersion(p.text)
return getClosestKnownClientApiVersion(p.text)
}
}
}

View File

@ -0,0 +1,87 @@
package org.moire.ultrasonic.api.subsonic
import com.fasterxml.jackson.databind.JavaType
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.ObjectReader
import java.lang.reflect.Type
import okhttp3.RequestBody
import okhttp3.ResponseBody
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
/**
* Retrofit Converter Factory which uses Jackson for conversion and maintains the
* version of the Subsonic API.
* @param notifier: callback function to call when the Subsonic API version changes
*/
class VersionAwareJacksonConverterFactory(
private val notifier: (SubsonicAPIVersions) -> Unit = {}
) : Converter.Factory() {
constructor(
notifier: (SubsonicAPIVersions) -> Unit = {},
mapper: ObjectMapper
) : this(notifier) {
this.mapper = mapper
jacksonConverterFactory = JacksonConverterFactory.create(mapper)
}
private var mapper: ObjectMapper? = null
private var jacksonConverterFactory: JacksonConverterFactory? = null
override fun responseBodyConverter(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
val javaType: JavaType = mapper!!.typeFactory.constructType(type)
val reader: ObjectReader? = mapper!!.readerFor(javaType)
return VersionAwareResponseBodyConverter<Any>(notifier, reader!!)
}
override fun requestBodyConverter(
type: Type,
parameterAnnotations: Array<Annotation>,
methodAnnotations: Array<Annotation>,
retrofit: Retrofit
): Converter<*, RequestBody>? {
return jacksonConverterFactory?.requestBodyConverter(
type, parameterAnnotations, methodAnnotations, retrofit
)
}
companion object {
@JvmOverloads // Guarding public API nullability.
fun create(
notifier: (SubsonicAPIVersions) -> Unit = {},
mapper: ObjectMapper? = ObjectMapper()
): VersionAwareJacksonConverterFactory {
if (mapper == null) throw NullPointerException("mapper == null")
return VersionAwareJacksonConverterFactory(notifier, mapper)
}
}
class VersionAwareResponseBodyConverter<T> (
private val notifier: (SubsonicAPIVersions) -> Unit = {},
private val adapter: ObjectReader
) : Converter<ResponseBody, T> {
override fun convert(value: ResponseBody): T {
value.use {
// The response stream contains the version of the API for parsing the stream
// to an object. Currently the parsing is independent from the version as new
// versions only contain extra optional fields.
val response: T = adapter.readValue(value.charStream())
if (response is SubsonicResponse) {
try {
notifier(response.version)
} catch (e: IllegalArgumentException) {
// no-op
}
}
return response
}
}
}
}

View File

@ -1,29 +1,16 @@
package org.moire.ultrasonic.api.subsonic.interceptors
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonToken
import java.io.IOException
import okhttp3.Interceptor
import okhttp3.Interceptor.Chain
import okhttp3.Response
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
private const val DEFAULT_PEEK_BYTE_COUNT = 1000L
/**
* Special [Interceptor] that adds client supported version to request and tries to update it
* from server response.
*
* Optionally [notifier] will be invoked on version change.
*
* Special [Interceptor] that adds client supported version to request
* @author Yahor Berdnikau
*/
internal class VersionInterceptor(
internal var protocolVersion: SubsonicAPIVersions,
private val notifier: (SubsonicAPIVersions) -> Unit = {}
internal var protocolVersion: SubsonicAPIVersions
) : Interceptor {
private val jsonFactory = JsonFactory()
override fun intercept(chain: Chain): okhttp3.Response {
val originalRequest = chain.request()
@ -38,44 +25,6 @@ internal class VersionInterceptor(
)
.build()
val response = chain.proceed(newRequest)
if (response.isSuccessful) {
val isJson = response.body()?.contentType()?.subtype()?.equals("json", true) ?: false
if (isJson) {
tryUpdateProtocolVersion(response)
}
}
return response
}
private fun tryUpdateProtocolVersion(response: Response) {
val content = response.peekBody(DEFAULT_PEEK_BYTE_COUNT).byteStream()
try {
val jsonReader = jsonFactory.createParser(content)
jsonReader.nextToken()
if (jsonReader.currentToken == JsonToken.START_OBJECT) {
while (
jsonReader.currentName != "version" &&
jsonReader.currentToken != null
) {
jsonReader.nextToken()
}
val versionStr = jsonReader.nextTextValue()
if (versionStr != null) {
try {
protocolVersion = SubsonicAPIVersions.fromApiVersion(versionStr)
notifier(protocolVersion)
} catch (e: IllegalArgumentException) {
// no-op
}
}
}
} catch (io: IOException) {
// no-op
} catch (parse: JsonParseException) {
// no-op
}
return chain.proceed(newRequest)
}
}

View File

@ -18,11 +18,11 @@ class SubsonicAPIVersionsTest(private val apiVersion: SubsonicAPIVersions) {
@Test
fun `Should proper convert api version to enum`() {
SubsonicAPIVersions.fromApiVersion(apiVersion.restApiVersion) `should equal` apiVersion
SubsonicAPIVersions.getClosestKnownClientApiVersion(apiVersion.restApiVersion) `should equal` apiVersion
}
@Test(expected = IllegalArgumentException::class)
fun `Should throw IllegalArgumentException for unknown api version`() {
SubsonicAPIVersions.fromApiVersion(apiVersion.restApiVersion.substring(0, 2))
SubsonicAPIVersions.getClosestKnownClientApiVersion(apiVersion.restApiVersion.substring(0, 2))
}
}

View File

@ -23,6 +23,7 @@ import android.os.Handler;
import android.util.Log;
import com.fasterxml.jackson.core.JsonParseException;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException;
import org.moire.ultrasonic.service.SubsonicRESTException;
import org.moire.ultrasonic.subsonic.RestErrorMapper;
@ -86,7 +87,10 @@ public abstract class BackgroundTask<T> implements ProgressListener
} else {
return activity.getResources().getString(R.string.background_task_ssl_error);
}
} else if (error instanceof IOException) {
} else if (error instanceof ApiNotSupportedException) {
return activity.getResources().getString(R.string.background_task_unsupported_api,
((ApiNotSupportedException) error).getServerApiVersion());
} else if (error instanceof IOException) {
return activity.getResources().getString(R.string.background_task_network_error);
} else if (error instanceof SubsonicRESTException) {
return RestErrorMapper.getLocalizedErrorMessage((SubsonicRESTException) error, activity);

View File

@ -78,7 +78,7 @@ val musicServiceModule = module(MUSIC_SERVICE_CONTEXT) {
baseUrl = "http://localhost",
username = "",
password = "",
minimalProtocolVersion = SubsonicAPIVersions.fromApiVersion(
minimalProtocolVersion = SubsonicAPIVersions.getClosestKnownClientApiVersion(
Constants.REST_PROTOCOL_VERSION
),
clientID = Constants.REST_CLIENT_ID,
@ -91,7 +91,7 @@ val musicServiceModule = module(MUSIC_SERVICE_CONTEXT) {
baseUrl = serverUrl,
username = username,
password = password,
minimalProtocolVersion = SubsonicAPIVersions.fromApiVersion(
minimalProtocolVersion = SubsonicAPIVersions.getClosestKnownClientApiVersion(
Constants.REST_PROTOCOL_VERSION
),
clientID = Constants.REST_CLIENT_ID,

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Lade&#8230;</string>
<string name="background_task.network_error">Ein Netzwerkfehler ist aufgetreten. Bitte die Serveradresse prüfen oder später noch einmal versuchen.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Dieses Programm benötigt eine Netzwerkverbindung. Bitte das WLAN oder Mobilfunk einschalten.</string>
<string name="background_task.not_found">Ressource nicht gefunden. Bitte die Serveradresse überprüfen.</string>
<string name="background_task.parse_error">Antwort nicht verstanden. Bitte die Serveradresse überprüfen.</string>

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Cargando&#8230;</string>
<string name="background_task.network_error">Se ha producido un error de red. Por favor comprueba la dirección del servidor o reinténtalo mas tarde.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Este programa requiere acceso a la red. Por favor enciende la Wi-Fi o la red móvil.</string>
<string name="background_task.not_found">Recurso no encontrado. Por favor comprueba la dirección del servidor.</string>
<string name="background_task.parse_error">No se entiende la respuesta. Por favor comprueba la dirección del servidor.</string>

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Chargement&#8230;</string>
<string name="background_task.network_error">Une erreur réseau est survenue. Veuillez vérifier l\'adresse du serveur ou réessayer plus tard.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Cette application requiert un accès au réseau. Veuillez activer le Wi-Fi ou le réseau mobile.</string>
<string name="background_task.not_found">Ressources introuvables. Veuillez vérifier l\'adresse du serveur.</string>
<string name="background_task.parse_error">Réponse incorrecte. Veuillez vérifier l\'adresse du serveur.</string>

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Betöltés&#8230;</string>
<string name="background_task.network_error">Hálózati hiba történt! Kérjük, ellenőrizze a kiszolgáló címét vagy próbálja később!</string>
<string name="background_task.unsupported_api">A v%1$s verziójú Szerver api nem támogatja ezt a funkciót.</string>
<string name="background_task.no_network">Az alkalmazás hálózati hozzáférést igényel. Kérjük, kapcsolja be a Wi-Fi-t vagy a mobilhálózatot!</string>
<string name="background_task.not_found">Az erőforrás nem található! Kérjük, ellenőrizze a kiszolgáló címét!</string>
<string name="background_task.parse_error">Értelmezhetetlen válasz! Kérjük, ellenőrizze a kiszolgáló címét!</string>

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Bezig met laden&#8230;</string>
<string name="background_task.network_error">Er is een netwerkfout opgetreden. Controleer het serveradres of probeer het later opnieuw.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Deze app vereist netwerktoegang. Schakel Wi-Fi of mobiel internet in.</string>
<string name="background_task.not_found">Bron niet gevonden. Controleer het serveradres.</string>
<string name="background_task.parse_error">Het antwoord werd niet begrepen. Controleer het serveradres.</string>

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Ładowanie&#8230;</string>
<string name="background_task.network_error">Wystąpił błąd sieci. Proszę sprawdzić adres serwera i spróbować później.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Ta aplikacja wymaga dostępu do sieci. Proszę włączyć wi-fi lub dane komórkowe.</string>
<string name="background_task.not_found">Nie znaleziono zasobów. Proszę sprawdzić adres serwera.</string>
<string name="background_task.parse_error">Brak prawidłowej odpowiedzi. Proszę sprawdzić adres serwera.</string>

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Carregando&#8230;</string>
<string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Este aplicativo requer acesso à rede. Ligue o Wi-Fi ou a rede de dados.</string>
<string name="background_task.not_found">Recurso não encontrado. Verifique o endereço do servidor.</string>
<string name="background_task.parse_error">Não entendi a resposta. Verifique o endereço do servidor.</string>

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Carregando&#8230;</string>
<string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">Este aplicativo requer acesso à rede. Ligue o Wi-Fi ou a rede de dados.</string>
<string name="background_task.not_found">Recurso não encontrado. Verifique o endereço do servidor.</string>
<string name="background_task.parse_error">Não entendi a resposta. Verifique o endereço do servidor.</string>

View File

@ -3,6 +3,7 @@
<string name="background_task.loading">Loading&#8230;</string>
<string name="background_task.network_error">A network error occurred. Please check the server address or try again later.</string>
<string name="background_task.unsupported_api">Server api v%1$s does not support this function.</string>
<string name="background_task.no_network">This program requires network access. Please turn on Wi-Fi or mobile network.</string>
<string name="background_task.not_found">Resource not found. Please check the server address.</string>
<string name="background_task.parse_error">Didn\'t understand the reply. Please check the server address.</string>