From 2dd179a6fcb800551c916173a0e77460f8a6a9d4 Mon Sep 17 00:00:00 2001 From: Allegra Date: Fri, 17 May 2024 20:17:00 -0400 Subject: [PATCH 1/7] Optionally follow a redirect from the server in discovery if it's a subpath of the original. Adds a parameter to RecommendedServerDiscovery.discover() that indicates if we should follow a redirect from the given servers. If true, send a HEAD request to the given servers, and if it returns a redirect code then add the redirect location to the collection of servers to check, but only if the redirected location is on the same host as the original. Prefer non-redirected addresses over those that are the result of a redirect. Pass the original and possible redirected address back through the RecommendedServerInfo object for use. --- .../org/jellyfin/sdk/api/ktor/KtorClient.kt | 7 ++ .../org/jellyfin/sdk/api/ktor/KtorClient.kt | 83 ++++++++++++ .../org/jellyfin/sdk/api/client/ApiClient.kt | 6 + .../jellyfin/sdk/api/client/HeadResponse.kt | 6 + .../client/exception/NoRedirectException.kt | 9 ++ .../discovery/RecommendedServerDiscovery.kt | 118 ++++++++++++++++-- .../sdk/discovery/RecommendedServerInfo.kt | 2 + .../sdk/discovery/RecommendedServerIssue.kt | 5 + 8 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/exception/NoRedirectException.kt diff --git a/jellyfin-api-ktor/src/commonMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt b/jellyfin-api-ktor/src/commonMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt index a4db518e4..6fbae2165 100644 --- a/jellyfin-api-ktor/src/commonMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt +++ b/jellyfin-api-ktor/src/commonMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt @@ -1,6 +1,7 @@ package org.jellyfin.sdk.api.ktor import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.HeadResponse import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.HttpMethod import org.jellyfin.sdk.api.client.RawResponse @@ -24,4 +25,10 @@ public expect open class KtorClient( queryParameters: Map, requestBody: Any?, ): RawResponse + + public override suspend fun headRequest( + pathTemplate: String, + pathParameters: Map, + queryParameters: Map, + ): HeadResponse } diff --git a/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt b/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt index a26e13f20..c11e70454 100644 --- a/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt +++ b/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt @@ -19,12 +19,14 @@ import io.ktor.util.toMap import kotlinx.serialization.SerializationException import mu.KotlinLogging import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.HeadResponse import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.HttpMethod import org.jellyfin.sdk.api.client.RawResponse import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.exception.InvalidContentException import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.exception.NoRedirectException import org.jellyfin.sdk.api.client.exception.SecureConnectionException import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.exception.ssl.BadPeerSSLKeyException @@ -179,6 +181,87 @@ public actual open class KtorClient actual constructor( } } + public actual override suspend fun headRequest( + pathTemplate: String, + pathParameters: Map, + queryParameters: Map, + ): HeadResponse { + val url = createUrl(pathTemplate, pathParameters, queryParameters) + + // Log HTTP call with access token removed + val logger = KotlinLogging.logger {} + logger.info { + val safeUrl = accessToken?.let { url.replace(it, "******") } ?: url + "HEAD $safeUrl" + } + + try { + val response = client.request(url) { + this.method = KtorHttpMethod.Head + + header( + key = HttpHeaders.Accept, + value = HEADER_ACCEPT, + ) + + header( + key = HttpHeaders.Authorization, + value = AuthorizationHeaderBuilder.buildHeader( + clientName = clientInfo.name, + clientVersion = clientInfo.version, + deviceId = deviceInfo.id, + deviceName = deviceInfo.name, + accessToken = accessToken + ) + ) + } + + // Check HTTP status for a redirect + if (response.status.value !in (300 until 400)) throw NoRedirectException(response.status.value) + // Return custom response instance + return HeadResponse(response.status.value, response.headers.toMap()) + } catch (err: UnknownHostException) { + logger.debug(err) { "HTTP host unreachable" } + throw TimeoutException("HTTP host unreachable", err) + } catch (err: HttpRequestTimeoutException) { + logger.debug(err) { "HTTP request timed out" } + throw TimeoutException("HTTP request timed out", err) + } catch (err: ConnectTimeoutException) { + logger.debug(err) { "Connection timed out" } + throw TimeoutException("Connection timed out", err) + } catch (err: SocketTimeoutException) { + logger.debug(err) { "Socket timed out" } + throw TimeoutException("Socket timed out", err) + } catch (err: ConnectException) { + logger.debug(err) { "Connection failed" } + throw TimeoutException("Connection failed", err) + } catch (err: NoTransformationFoundException) { + logger.error(err) { "Requested model does not exist" } + throw InvalidContentException("Requested model does not exist", err) + } catch (err: SerializationException) { + logger.error(err) { "Serialization failed" } + throw InvalidContentException("Serialization failed", err) + } catch (err: SSLKeyException) { + logger.error(err) { "Invalid SSL peer key format" } + throw BadPeerSSLKeyException("Invalid SSL peer key format", err) + } catch (err: SSLPeerUnverifiedException) { + logger.error(err) { "Couldn't authenticate peer" } + throw PeerNotAuthenticatedException("Couldn't authenticate peer", err) + } catch (err: SSLHandshakeException) { + logger.error(err) { "SSL Invalid handshake" } + throw HandshakeCertificateException("Invalid handshake", err) + } catch (err: SSLProtocolException) { + logger.error(err) { "Invalid SSL protocol implementation" } + throw InvalidSSLProtocolImplementationException("Invalid SSL protocol implementation", err) + } catch (err: SSLException) { + logger.error(err) { "Unknown SSL error occurred" } + throw SecureConnectionException("Unknown SSL error occurred", err) + } catch (err: IOException) { + logger.error(err) { "Unknown IO error occurred!" } + throw ApiClientException("Unknown IO error occurred!", err) + } + } + override val webSocket: SocketApi by _webSocket private fun HttpMethod.asKtorHttpMethod(): KtorHttpMethod = when (this) { diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/ApiClient.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/ApiClient.kt index 4701f353c..67674e41b 100644 --- a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/ApiClient.kt +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/ApiClient.kt @@ -93,6 +93,12 @@ public abstract class ApiClient { requestBody: Any? = null, ): RawResponse + public abstract suspend fun headRequest( + pathTemplate: String, + pathParameters: Map = emptyMap(), + queryParameters: Map = emptyMap(), + ): HeadResponse + /** * Get the instance of the SocketApi for this ApiClient. * @see SocketApi diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt new file mode 100644 index 000000000..f08877053 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt @@ -0,0 +1,6 @@ +package org.jellyfin.sdk.api.client + +public class HeadResponse( + public val status: Int, + public val headers: Map> +) diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/exception/NoRedirectException.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/exception/NoRedirectException.kt new file mode 100644 index 000000000..aa510a157 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/exception/NoRedirectException.kt @@ -0,0 +1,9 @@ +package org.jellyfin.sdk.api.client.exception + +/** + * Exception for when we are looking for redirects and don't find one + */ +public class NoRedirectException( + public val status: Int, + cause: Throwable? = null, +) : ApiClientException("HTTP status is not a redirect. status: $status", cause) diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt index 85876271a..ef00f3eb0 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt @@ -1,5 +1,9 @@ package org.jellyfin.sdk.discovery +import io.ktor.http.HttpHeaders +import io.ktor.http.URLBuilder +import io.ktor.http.isRelativePath +import io.ktor.http.takeFrom import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -13,6 +17,7 @@ import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.exception.InvalidContentException import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.exception.NoRedirectException import org.jellyfin.sdk.api.client.exception.SecureConnectionException import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.extensions.systemApi @@ -35,11 +40,19 @@ public class RecommendedServerDiscovery constructor( } private data class SystemInfoResult( - val address: String, + val server: RedirectInfo, val systemInfo: Result, val responseTime: Long, ) + private data class RedirectInfo( + val originalAddress: String, + val redirectAddress: String? = null, + ) { + fun isRedirect() = redirectAddress != null && originalAddress != redirectAddress + fun getAddress() = redirectAddress ?: originalAddress + } + @Suppress("MagicNumber") private fun assignScore(result: SystemInfoResult): RecommendedServerInfo { val systemInfo = result.systemInfo.getOrNull() @@ -101,19 +114,27 @@ public class RecommendedServerDiscovery constructor( } } + // prefer non-redirected addresses + if (result.server.isRedirect()) { + issues.add(RecommendedServerIssue.RedirectedResponse()) + scores.add(RecommendedServerInfoScore.GOOD) + } + // Calculate score, pick the lowest from the collection or use GREAT when no scores (and issues) added val score = scores.minByOrNull { it.score } ?: RecommendedServerInfoScore.GREAT // Return results return RecommendedServerInfo( - result.address, + result.server.getAddress(), result.responseTime, score, issues, result.systemInfo, + if (result.server.isRedirect()) result.server.originalAddress else null ) } - private suspend fun getSystemInfoResult(address: String): SystemInfoResult { + private suspend fun getSystemInfoResult(server: RedirectInfo): SystemInfoResult { + val address = server.getAddress() logger.info { "Requesting public system info for $address" } val client = jellyfin.createApi( @@ -148,27 +169,75 @@ public class RecommendedServerDiscovery constructor( val responseTime = currentTimeMillis() - responseTimeStart return SystemInfoResult( - address = address, + server = server, systemInfo = info.map(Response::content), responseTime = responseTime, ) } + private suspend fun getRedirectInfo(server: String): RedirectInfo? { + logger.info { "Requesting header info for $server" } + + val client = jellyfin.createApi( + baseUrl = server, + httpClientOptions = HttpClientOptions( + followRedirects = false, + connectTimeout = HTTP_TIMEOUT, + requestTimeout = HTTP_TIMEOUT, + socketTimeout = HTTP_TIMEOUT, + ), + ) + + val headersResult = try { + val response = client.headRequest("") + if (response.status in (300 until 400)) Result.success(response.headers) + else Result.failure(NoRedirectException(response.status)) + } catch (err: TimeoutException) { + logger.debug(err) { "Could not connect to $server" } + Result.failure(err) + } catch (err: InvalidStatusException) { + logger.debug(err) { "Received unexpected status ${err.status} from $server" } + Result.failure(err) + } catch (err: InvalidContentException) { + logger.debug(err) { "Could not parse response from $server" } + Result.failure(err) + } catch (err: ApiClientException) { + logger.debug(err) { "Unable to get response from $server" } + Result.failure(err) + } + + // make sure there are headers + val headers = headersResult.getOrElse { return null } + + // make sure there is a Location header and extract it from the List + val location = headers[HttpHeaders.Location]?.firstOrNull() ?: return null + + // only follow the redirect for subpaths + val locationUrl = URLBuilder(location).build() + val serverUrl = URLBuilder(server).build() + if (locationUrl.isRelativePath) { + return RedirectInfo(server, URLBuilder(serverUrl).takeFrom(serverUrl).buildString()) + } + if (locationUrl.host == serverUrl.host) { + return RedirectInfo(server, location) + } + return null + } + /** - * Discover all servers in the [servers] flow and retrieve the public system information to assign a score. - * Returned servers are not ordered by score. Use [minimumScore] to automatically remove bad matches. + * Test and score each server, including any discovered through redirects */ - public suspend fun discover( - servers: Collection, + private suspend fun discover( + servers: Collection, minimumScore: RecommendedServerInfoScore, ): Collection = withContext(Dispatchers.IO) { val semaphore = Semaphore(MAX_SIMULTANEOUS_RETRIEVALS) servers - .map { address -> + .map { server -> async { semaphore.withPermit { - getSystemInfoResult(address).let(::assignScore) + getSystemInfoResult(server).let(::assignScore) } } } @@ -178,4 +247,33 @@ public class RecommendedServerDiscovery constructor( serverInfo.score.score >= minimumScore.score } } + + /** + * Discover all servers in the [servers] flow and retrieve the public system information to assign a score. + * Returned servers are not ordered by score. Use [minimumScore] to automatically remove bad matches. + * If [followRedirects] is true, also check the redirected location if it is a subpath of the original address. + */ + public suspend fun discover( + servers: Collection, + minimumScore: RecommendedServerInfoScore, + followRedirects: Boolean = false, + ): Collection = withContext(Dispatchers.IO) { + val semaphore = Semaphore(MAX_SIMULTANEOUS_RETRIEVALS) + var allServers = servers.map { RedirectInfo(it) } + + if (followRedirects) { + val redirects = servers + .map { address -> + async { + semaphore.withPermit { + getRedirectInfo(address) + } + } + } + .awaitAll() + allServers = allServers.plus(redirects.filterNotNull()) + } + + discover(allServers, minimumScore) + } } diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerInfo.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerInfo.kt index cc18c2860..f0a6a4a57 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerInfo.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerInfo.kt @@ -8,10 +8,12 @@ public data class RecommendedServerInfo( val score: RecommendedServerInfoScore, val issues: Collection, val systemInfo: Result, + val originalAddress: String? = null, ) { /** * The issues are ordered by importance. When showing a single issue to an end user you * normally want to show the first one. */ public fun firstIssueOrNull(): RecommendedServerIssue? = issues.firstOrNull() + public fun isRedirect(): Boolean = originalAddress != null && originalAddress != address } diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerIssue.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerIssue.kt index 8f4a1b091..8632056f4 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerIssue.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerIssue.kt @@ -50,4 +50,9 @@ public sealed interface RecommendedServerIssue { * The system information response was slow. */ public data class SlowResponse(public val responseTime: Long) : RecommendedServerIssue + + /** + * The address was the result of a redirect + */ + public data class RedirectedResponse(public val isRedirect: Boolean = true) : RecommendedServerIssue } From d8ed15718efa137dadbbfb943b381473550a800a Mon Sep 17 00:00:00 2001 From: Allegra Date: Sat, 18 May 2024 01:28:54 -0400 Subject: [PATCH 2/7] Fix bugs with retrieving headers and parsing redirects. Use the Headers object instead of a standard map to provide case-insensitive access to the headers. Correctly override the path on the current URL when given a path-only redirect --- .../kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt | 2 +- .../org/jellyfin/sdk/api/client/HeadResponse.kt | 4 +++- .../org/jellyfin/sdk/discovery/DiscoveryService.kt | 4 +++- .../sdk/discovery/RecommendedServerDiscovery.kt | 11 +++++++---- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt b/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt index c11e70454..de60c451a 100644 --- a/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt +++ b/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt @@ -219,7 +219,7 @@ public actual open class KtorClient actual constructor( // Check HTTP status for a redirect if (response.status.value !in (300 until 400)) throw NoRedirectException(response.status.value) // Return custom response instance - return HeadResponse(response.status.value, response.headers.toMap()) + return HeadResponse(response.status.value, response.headers) } catch (err: UnknownHostException) { logger.debug(err) { "HTTP host unreachable" } throw TimeoutException("HTTP host unreachable", err) diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt index f08877053..e746b76f2 100644 --- a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt @@ -1,6 +1,8 @@ package org.jellyfin.sdk.api.client +import io.ktor.http.Headers + public class HeadResponse( public val status: Int, - public val headers: Map> + public val headers: Headers ) diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/DiscoveryService.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/DiscoveryService.kt index 4b7a023ce..60b23c4d7 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/DiscoveryService.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/DiscoveryService.kt @@ -45,9 +45,11 @@ public class DiscoveryService( public suspend fun getRecommendedServers( servers: Collection, minimumScore: RecommendedServerInfoScore = RecommendedServerInfoScore.BAD, + followRedirects: Boolean = false, ): Collection = recommendedServerDiscovery.discover( servers = servers, - minimumScore = minimumScore + minimumScore = minimumScore, + followRedirects, ) /** diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt index ef00f3eb0..5acc47d99 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt @@ -2,8 +2,8 @@ package org.jellyfin.sdk.discovery import io.ktor.http.HttpHeaders import io.ktor.http.URLBuilder +import io.ktor.http.encodedPath import io.ktor.http.isRelativePath -import io.ktor.http.takeFrom import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -209,14 +209,16 @@ public class RecommendedServerDiscovery constructor( // make sure there are headers val headers = headersResult.getOrElse { return null } - // make sure there is a Location header and extract it from the List - val location = headers[HttpHeaders.Location]?.firstOrNull() ?: return null + // make sure there is a Location header and extract it from the map + val location = headers[HttpHeaders.Location] ?: return null // only follow the redirect for subpaths val locationUrl = URLBuilder(location).build() val serverUrl = URLBuilder(server).build() if (locationUrl.isRelativePath) { - return RedirectInfo(server, URLBuilder(serverUrl).takeFrom(serverUrl).buildString()) + return RedirectInfo(server, URLBuilder(server).apply { + this.encodedPath = locationUrl.encodedPath + }.buildString()) } if (locationUrl.host == serverUrl.host) { return RedirectInfo(server, location) @@ -231,6 +233,7 @@ public class RecommendedServerDiscovery constructor( servers: Collection, minimumScore: RecommendedServerInfoScore, ): Collection = withContext(Dispatchers.IO) { + logger.info("Ranking ${servers.size} addresses") val semaphore = Semaphore(MAX_SIMULTANEOUS_RETRIEVALS) servers From ac33b589397cf7a2a5d53ab806be512eb626c582 Mon Sep 17 00:00:00 2001 From: Allegra Date: Sat, 18 May 2024 01:36:21 -0400 Subject: [PATCH 3/7] Run apiDump gradle task --- jellyfin-api-ktor/api/jellyfin-api-ktor.api | 1 + jellyfin-api/api/jellyfin-api.api | 14 ++++++++++ jellyfin-core/api/android/jellyfin-core.api | 30 ++++++++++++++++----- jellyfin-core/api/jvm/jellyfin-core.api | 30 ++++++++++++++++----- 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/jellyfin-api-ktor/api/jellyfin-api-ktor.api b/jellyfin-api-ktor/api/jellyfin-api-ktor.api index b298d2081..d26ccbef4 100644 --- a/jellyfin-api-ktor/api/jellyfin-api-ktor.api +++ b/jellyfin-api-ktor/api/jellyfin-api-ktor.api @@ -7,6 +7,7 @@ public class org/jellyfin/sdk/api/ktor/KtorClient : org/jellyfin/sdk/api/client/ public fun getDeviceInfo ()Lorg/jellyfin/sdk/model/DeviceInfo; public fun getHttpClientOptions ()Lorg/jellyfin/sdk/api/client/HttpClientOptions; public fun getWebSocket ()Lorg/jellyfin/sdk/api/sockets/SocketApi; + public fun headRequest (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun request (Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun setAccessToken (Ljava/lang/String;)V public fun setBaseUrl (Ljava/lang/String;)V diff --git a/jellyfin-api/api/jellyfin-api.api b/jellyfin-api/api/jellyfin-api.api index 26faffbda..ac68a069c 100644 --- a/jellyfin-api/api/jellyfin-api.api +++ b/jellyfin-api/api/jellyfin-api.api @@ -12,6 +12,8 @@ public abstract class org/jellyfin/sdk/api/client/ApiClient { public abstract fun getHttpClientOptions ()Lorg/jellyfin/sdk/api/client/HttpClientOptions; public final fun getOrCreateApi (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;)Lorg/jellyfin/sdk/api/operations/Api; public abstract fun getWebSocket ()Lorg/jellyfin/sdk/api/sockets/SocketApi; + public abstract fun headRequest (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun headRequest$default (Lorg/jellyfin/sdk/api/client/ApiClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun request (Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun request$default (Lorg/jellyfin/sdk/api/client/ApiClient;Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun setAccessToken (Ljava/lang/String;)V @@ -25,6 +27,12 @@ public abstract class org/jellyfin/sdk/api/client/ApiClient { public final class org/jellyfin/sdk/api/client/ApiClient$Companion { } +public final class org/jellyfin/sdk/api/client/HeadResponse { + public fun (ILio/ktor/http/Headers;)V + public final fun getHeaders ()Lio/ktor/http/Headers; + public final fun getStatus ()I +} + public final class org/jellyfin/sdk/api/client/HttpClientOptions { public synthetic fun (ZJJJLorg/jellyfin/sdk/api/sockets/SocketReconnectPolicy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (ZJJJLorg/jellyfin/sdk/api/sockets/SocketReconnectPolicy;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -110,6 +118,12 @@ public final class org/jellyfin/sdk/api/client/exception/MissingPathVariableExce public final fun getPath ()Ljava/lang/String; } +public final class org/jellyfin/sdk/api/client/exception/NoRedirectException : org/jellyfin/sdk/api/client/exception/ApiClientException { + public fun (ILjava/lang/Throwable;)V + public synthetic fun (ILjava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getStatus ()I +} + public class org/jellyfin/sdk/api/client/exception/SecureConnectionException : org/jellyfin/sdk/api/client/exception/ApiClientException { public fun ()V public fun (Ljava/lang/String;Ljava/lang/Throwable;)V diff --git a/jellyfin-core/api/android/jellyfin-core.api b/jellyfin-core/api/android/jellyfin-core.api index 4a30e66f7..ac861750f 100644 --- a/jellyfin-core/api/android/jellyfin-core.api +++ b/jellyfin-core/api/android/jellyfin-core.api @@ -130,9 +130,9 @@ public final class org/jellyfin/sdk/discovery/DiscoveryService { public static synthetic fun discoverLocalServers$default (Lorg/jellyfin/sdk/discovery/DiscoveryService;IIILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; public final fun getAddressCandidates (Ljava/lang/String;)Ljava/util/Collection; public final fun getRecommendedServers (Ljava/lang/String;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun getRecommendedServers (Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getRecommendedServers (Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun getRecommendedServers$default (Lorg/jellyfin/sdk/discovery/DiscoveryService;Ljava/lang/String;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun getRecommendedServers$default (Lorg/jellyfin/sdk/discovery/DiscoveryService;Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun getRecommendedServers$default (Lorg/jellyfin/sdk/discovery/DiscoveryService;Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class org/jellyfin/sdk/discovery/LocalServerDiscovery { @@ -152,26 +152,31 @@ public final class org/jellyfin/sdk/discovery/LocalServerDiscovery$Companion { public final class org/jellyfin/sdk/discovery/RecommendedServerDiscovery { public fun (Lorg/jellyfin/sdk/Jellyfin;)V - public final fun discover (Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun discover (Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun discover$default (Lorg/jellyfin/sdk/discovery/RecommendedServerDiscovery;Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class org/jellyfin/sdk/discovery/RecommendedServerInfo { - public fun (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;)V + public fun (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()J public final fun component3 ()Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore; public final fun component4 ()Ljava/util/Collection; public final fun component5-d1pmJ48 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;)Lorg/jellyfin/sdk/discovery/RecommendedServerInfo; - public static synthetic fun copy$default (Lorg/jellyfin/sdk/discovery/RecommendedServerInfo;Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Lkotlin/Result;ILjava/lang/Object;)Lorg/jellyfin/sdk/discovery/RecommendedServerInfo; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;Ljava/lang/String;)Lorg/jellyfin/sdk/discovery/RecommendedServerInfo; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/discovery/RecommendedServerInfo;Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Lkotlin/Result;Ljava/lang/String;ILjava/lang/Object;)Lorg/jellyfin/sdk/discovery/RecommendedServerInfo; public fun equals (Ljava/lang/Object;)Z public final fun firstIssueOrNull ()Lorg/jellyfin/sdk/discovery/RecommendedServerIssue; public final fun getAddress ()Ljava/lang/String; public final fun getIssues ()Ljava/util/Collection; + public final fun getOriginalAddress ()Ljava/lang/String; public final fun getResponseTime ()J public final fun getScore ()Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore; public final fun getSystemInfo-d1pmJ48 ()Ljava/lang/Object; public fun hashCode ()I + public final fun isRedirect ()Z public fun toString ()Ljava/lang/String; } @@ -226,6 +231,19 @@ public final class org/jellyfin/sdk/discovery/RecommendedServerIssue$OutdatedSer public fun toString ()Ljava/lang/String; } +public final class org/jellyfin/sdk/discovery/RecommendedServerIssue$RedirectedResponse : org/jellyfin/sdk/discovery/RecommendedServerIssue { + public fun ()V + public fun (Z)V + public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun copy (Z)Lorg/jellyfin/sdk/discovery/RecommendedServerIssue$RedirectedResponse; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/discovery/RecommendedServerIssue$RedirectedResponse;ZILjava/lang/Object;)Lorg/jellyfin/sdk/discovery/RecommendedServerIssue$RedirectedResponse; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public final fun isRedirect ()Z + public fun toString ()Ljava/lang/String; +} + public final class org/jellyfin/sdk/discovery/RecommendedServerIssue$SecureConnectionFailed : org/jellyfin/sdk/discovery/RecommendedServerIssue { public fun (Lorg/jellyfin/sdk/api/client/exception/SecureConnectionException;)V public final fun component1 ()Lorg/jellyfin/sdk/api/client/exception/SecureConnectionException; diff --git a/jellyfin-core/api/jvm/jellyfin-core.api b/jellyfin-core/api/jvm/jellyfin-core.api index faa9cbc1b..e241a3265 100644 --- a/jellyfin-core/api/jvm/jellyfin-core.api +++ b/jellyfin-core/api/jvm/jellyfin-core.api @@ -122,9 +122,9 @@ public final class org/jellyfin/sdk/discovery/DiscoveryService { public static synthetic fun discoverLocalServers$default (Lorg/jellyfin/sdk/discovery/DiscoveryService;IIILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; public final fun getAddressCandidates (Ljava/lang/String;)Ljava/util/Collection; public final fun getRecommendedServers (Ljava/lang/String;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun getRecommendedServers (Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getRecommendedServers (Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun getRecommendedServers$default (Lorg/jellyfin/sdk/discovery/DiscoveryService;Ljava/lang/String;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun getRecommendedServers$default (Lorg/jellyfin/sdk/discovery/DiscoveryService;Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun getRecommendedServers$default (Lorg/jellyfin/sdk/discovery/DiscoveryService;Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class org/jellyfin/sdk/discovery/LocalServerDiscovery { @@ -144,26 +144,31 @@ public final class org/jellyfin/sdk/discovery/LocalServerDiscovery$Companion { public final class org/jellyfin/sdk/discovery/RecommendedServerDiscovery { public fun (Lorg/jellyfin/sdk/Jellyfin;)V - public final fun discover (Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun discover (Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun discover$default (Lorg/jellyfin/sdk/discovery/RecommendedServerDiscovery;Ljava/util/Collection;Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class org/jellyfin/sdk/discovery/RecommendedServerInfo { - public fun (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;)V + public fun (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()J public final fun component3 ()Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore; public final fun component4 ()Ljava/util/Collection; public final fun component5-d1pmJ48 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;)Lorg/jellyfin/sdk/discovery/RecommendedServerInfo; - public static synthetic fun copy$default (Lorg/jellyfin/sdk/discovery/RecommendedServerInfo;Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Lkotlin/Result;ILjava/lang/Object;)Lorg/jellyfin/sdk/discovery/RecommendedServerInfo; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Ljava/lang/Object;Ljava/lang/String;)Lorg/jellyfin/sdk/discovery/RecommendedServerInfo; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/discovery/RecommendedServerInfo;Ljava/lang/String;JLorg/jellyfin/sdk/discovery/RecommendedServerInfoScore;Ljava/util/Collection;Lkotlin/Result;Ljava/lang/String;ILjava/lang/Object;)Lorg/jellyfin/sdk/discovery/RecommendedServerInfo; public fun equals (Ljava/lang/Object;)Z public final fun firstIssueOrNull ()Lorg/jellyfin/sdk/discovery/RecommendedServerIssue; public final fun getAddress ()Ljava/lang/String; public final fun getIssues ()Ljava/util/Collection; + public final fun getOriginalAddress ()Ljava/lang/String; public final fun getResponseTime ()J public final fun getScore ()Lorg/jellyfin/sdk/discovery/RecommendedServerInfoScore; public final fun getSystemInfo-d1pmJ48 ()Ljava/lang/Object; public fun hashCode ()I + public final fun isRedirect ()Z public fun toString ()Ljava/lang/String; } @@ -218,6 +223,19 @@ public final class org/jellyfin/sdk/discovery/RecommendedServerIssue$OutdatedSer public fun toString ()Ljava/lang/String; } +public final class org/jellyfin/sdk/discovery/RecommendedServerIssue$RedirectedResponse : org/jellyfin/sdk/discovery/RecommendedServerIssue { + public fun ()V + public fun (Z)V + public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun copy (Z)Lorg/jellyfin/sdk/discovery/RecommendedServerIssue$RedirectedResponse; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/discovery/RecommendedServerIssue$RedirectedResponse;ZILjava/lang/Object;)Lorg/jellyfin/sdk/discovery/RecommendedServerIssue$RedirectedResponse; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public final fun isRedirect ()Z + public fun toString ()Ljava/lang/String; +} + public final class org/jellyfin/sdk/discovery/RecommendedServerIssue$SecureConnectionFailed : org/jellyfin/sdk/discovery/RecommendedServerIssue { public fun (Lorg/jellyfin/sdk/api/client/exception/SecureConnectionException;)V public final fun component1 ()Lorg/jellyfin/sdk/api/client/exception/SecureConnectionException; From 1b5fb990cc1159fecfe4815b56006d3470fbe174 Mon Sep 17 00:00:00 2001 From: Allegra Date: Sat, 18 May 2024 10:09:49 -0400 Subject: [PATCH 4/7] Refactor to reduce code duplication --- .../org/jellyfin/sdk/api/ktor/KtorClient.kt | 2 + .../org/jellyfin/sdk/api/ktor/KtorClient.kt | 123 +++++------------- .../org/jellyfin/sdk/api/client/ApiClient.kt | 4 +- .../jellyfin/sdk/api/client/RawResponse.kt | 9 +- .../discovery/RecommendedServerDiscovery.kt | 28 ++-- 5 files changed, 56 insertions(+), 110 deletions(-) diff --git a/jellyfin-api-ktor/src/commonMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt b/jellyfin-api-ktor/src/commonMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt index 6fbae2165..1c6ba4b72 100644 --- a/jellyfin-api-ktor/src/commonMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt +++ b/jellyfin-api-ktor/src/commonMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt @@ -24,11 +24,13 @@ public expect open class KtorClient( pathParameters: Map, queryParameters: Map, requestBody: Any?, + expectedResponseCodes: IntRange, ): RawResponse public override suspend fun headRequest( pathTemplate: String, pathParameters: Map, queryParameters: Map, + expectedResponseCodes: IntRange, ): HeadResponse } diff --git a/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt b/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt index de60c451a..9bc06b558 100644 --- a/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt +++ b/jellyfin-api-ktor/src/jvmMain/kotlin/org/jellyfin/sdk/api/ktor/KtorClient.kt @@ -14,8 +14,6 @@ import io.ktor.content.ByteArrayContent import io.ktor.content.TextContent import io.ktor.http.ContentType import io.ktor.http.HttpHeaders -import io.ktor.http.isSuccess -import io.ktor.util.toMap import kotlinx.serialization.SerializationException import mu.KotlinLogging import org.jellyfin.sdk.api.client.ApiClient @@ -26,7 +24,6 @@ import org.jellyfin.sdk.api.client.RawResponse import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.exception.InvalidContentException import org.jellyfin.sdk.api.client.exception.InvalidStatusException -import org.jellyfin.sdk.api.client.exception.NoRedirectException import org.jellyfin.sdk.api.client.exception.SecureConnectionException import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.exception.ssl.BadPeerSSLKeyException @@ -86,26 +83,53 @@ public actual open class KtorClient actual constructor( } } - @Suppress("ThrowsCount") public actual override suspend fun request( method: HttpMethod, pathTemplate: String, pathParameters: Map, queryParameters: Map, requestBody: Any?, + expectedResponseCodes: IntRange, ): RawResponse { - val url = createUrl(pathTemplate, pathParameters, queryParameters) + return generalRequest( + method = method.asKtorHttpMethod(), + url = createUrl(pathTemplate, pathParameters, queryParameters), + requestBody = requestBody, + expectedResponseCodes = 200 until 300, + ) + } + public actual override suspend fun headRequest( + pathTemplate: String, + pathParameters: Map, + queryParameters: Map, + expectedResponseCodes: IntRange, + ): HeadResponse { + return generalRequest( + method = KtorHttpMethod.Head, + url = createUrl(pathTemplate, pathParameters, queryParameters), + requestBody = null, + expectedResponseCodes = expectedResponseCodes, + ).createHeadResponse() + } + + @Suppress("ThrowsCount") + private suspend fun generalRequest( + method: KtorHttpMethod, + url: String, + requestBody: Any?, + expectedResponseCodes: IntRange, + ) : RawResponse { // Log HTTP call with access token removed val logger = KotlinLogging.logger {} logger.info { val safeUrl = accessToken?.let { url.replace(it, "******") } ?: url - "$method $safeUrl" + "${method.value} $safeUrl" } try { val response = client.request(url) { - this.method = method.asKtorHttpMethod() + this.method = method header( key = HttpHeaders.Accept, @@ -136,90 +160,9 @@ public actual open class KtorClient actual constructor( } // Check HTTP status - if (!response.status.isSuccess()) throw InvalidStatusException(response.status.value) - // Return custom response instance - return RawResponse(response.bodyAsChannel(), response.status.value, response.headers.toMap()) - } catch (err: UnknownHostException) { - logger.debug(err) { "HTTP host unreachable" } - throw TimeoutException("HTTP host unreachable", err) - } catch (err: HttpRequestTimeoutException) { - logger.debug(err) { "HTTP request timed out" } - throw TimeoutException("HTTP request timed out", err) - } catch (err: ConnectTimeoutException) { - logger.debug(err) { "Connection timed out" } - throw TimeoutException("Connection timed out", err) - } catch (err: SocketTimeoutException) { - logger.debug(err) { "Socket timed out" } - throw TimeoutException("Socket timed out", err) - } catch (err: ConnectException) { - logger.debug(err) { "Connection failed" } - throw TimeoutException("Connection failed", err) - } catch (err: NoTransformationFoundException) { - logger.error(err) { "Requested model does not exist" } - throw InvalidContentException("Requested model does not exist", err) - } catch (err: SerializationException) { - logger.error(err) { "Serialization failed" } - throw InvalidContentException("Serialization failed", err) - } catch (err: SSLKeyException) { - logger.error(err) { "Invalid SSL peer key format" } - throw BadPeerSSLKeyException("Invalid SSL peer key format", err) - } catch (err: SSLPeerUnverifiedException) { - logger.error(err) { "Couldn't authenticate peer" } - throw PeerNotAuthenticatedException("Couldn't authenticate peer", err) - } catch (err: SSLHandshakeException) { - logger.error(err) { "SSL Invalid handshake" } - throw HandshakeCertificateException("Invalid handshake", err) - } catch (err: SSLProtocolException) { - logger.error(err) { "Invalid SSL protocol implementation" } - throw InvalidSSLProtocolImplementationException("Invalid SSL protocol implementation", err) - } catch (err: SSLException) { - logger.error(err) { "Unknown SSL error occurred" } - throw SecureConnectionException("Unknown SSL error occurred", err) - } catch (err: IOException) { - logger.error(err) { "Unknown IO error occurred!" } - throw ApiClientException("Unknown IO error occurred!", err) - } - } - - public actual override suspend fun headRequest( - pathTemplate: String, - pathParameters: Map, - queryParameters: Map, - ): HeadResponse { - val url = createUrl(pathTemplate, pathParameters, queryParameters) - - // Log HTTP call with access token removed - val logger = KotlinLogging.logger {} - logger.info { - val safeUrl = accessToken?.let { url.replace(it, "******") } ?: url - "HEAD $safeUrl" - } - - try { - val response = client.request(url) { - this.method = KtorHttpMethod.Head - - header( - key = HttpHeaders.Accept, - value = HEADER_ACCEPT, - ) - - header( - key = HttpHeaders.Authorization, - value = AuthorizationHeaderBuilder.buildHeader( - clientName = clientInfo.name, - clientVersion = clientInfo.version, - deviceId = deviceInfo.id, - deviceName = deviceInfo.name, - accessToken = accessToken - ) - ) - } - - // Check HTTP status for a redirect - if (response.status.value !in (300 until 400)) throw NoRedirectException(response.status.value) + if (response.status.value !in expectedResponseCodes) throw InvalidStatusException(response.status.value) // Return custom response instance - return HeadResponse(response.status.value, response.headers) + return RawResponse(response.bodyAsChannel(), response.status.value, response.headers) } catch (err: UnknownHostException) { logger.debug(err) { "HTTP host unreachable" } throw TimeoutException("HTTP host unreachable", err) diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/ApiClient.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/ApiClient.kt index 67674e41b..665541fbc 100644 --- a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/ApiClient.kt +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/ApiClient.kt @@ -91,12 +91,14 @@ public abstract class ApiClient { pathParameters: Map = emptyMap(), queryParameters: Map = emptyMap(), requestBody: Any? = null, + expectedResponseCodes: IntRange = 200 until 300 ): RawResponse public abstract suspend fun headRequest( - pathTemplate: String, + pathTemplate: String = "", pathParameters: Map = emptyMap(), queryParameters: Map = emptyMap(), + expectedResponseCodes: IntRange = 300 until 400 ): HeadResponse /** diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/RawResponse.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/RawResponse.kt index ceccad0f7..f867cadfd 100644 --- a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/RawResponse.kt +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/RawResponse.kt @@ -1,5 +1,7 @@ package org.jellyfin.sdk.api.client +import io.ktor.http.Headers +import io.ktor.util.toMap import io.ktor.utils.io.ByteReadChannel import kotlinx.serialization.SerializationException import mu.KotlinLogging @@ -9,7 +11,7 @@ import org.jellyfin.sdk.api.client.util.ApiSerializer public class RawResponse( public val body: ByteReadChannel, public val status: Int, - public val headers: Map>, + public val headers: Headers, ) { public suspend inline fun createContent(): T { val logger = KotlinLogging.logger {} @@ -23,5 +25,8 @@ public class RawResponse( } public suspend inline fun createResponse(): Response = - Response(createContent(), status, headers) + Response(createContent(), status, headers.toMap()) + + public fun createHeadResponse(): HeadResponse = + HeadResponse(status, headers) } diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt index 5acc47d99..578dcf2fd 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt @@ -2,7 +2,6 @@ package org.jellyfin.sdk.discovery import io.ktor.http.HttpHeaders import io.ktor.http.URLBuilder -import io.ktor.http.encodedPath import io.ktor.http.isRelativePath import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -21,6 +20,7 @@ import org.jellyfin.sdk.api.client.exception.NoRedirectException import org.jellyfin.sdk.api.client.exception.SecureConnectionException import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.util.UrlBuilder.buildUrl import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.util.currentTimeMillis @@ -28,7 +28,7 @@ import kotlin.time.Duration.Companion.seconds private val logger = KotlinLogging.logger {} -public class RecommendedServerDiscovery constructor( +public class RecommendedServerDiscovery( private val jellyfin: Jellyfin, ) { private companion object { @@ -189,15 +189,14 @@ public class RecommendedServerDiscovery constructor( ) val headersResult = try { - val response = client.headRequest("") - if (response.status in (300 until 400)) Result.success(response.headers) - else Result.failure(NoRedirectException(response.status)) + val response = client.headRequest() + Result.success(response.headers) } catch (err: TimeoutException) { logger.debug(err) { "Could not connect to $server" } Result.failure(err) } catch (err: InvalidStatusException) { - logger.debug(err) { "Received unexpected status ${err.status} from $server" } - Result.failure(err) + logger.debug(err) { "Received non-redirect status ${err.status} from $server" } + Result.failure(NoRedirectException(err.status)) } catch (err: InvalidContentException) { logger.debug(err) { "Could not parse response from $server" } Result.failure(err) @@ -212,17 +211,12 @@ public class RecommendedServerDiscovery constructor( // make sure there is a Location header and extract it from the map val location = headers[HttpHeaders.Location] ?: return null - // only follow the redirect for subpaths + // only follow the redirect if on the same host val locationUrl = URLBuilder(location).build() + if (locationUrl.isRelativePath) return RedirectInfo(server, buildUrl(server, location)) val serverUrl = URLBuilder(server).build() - if (locationUrl.isRelativePath) { - return RedirectInfo(server, URLBuilder(server).apply { - this.encodedPath = locationUrl.encodedPath - }.buildString()) - } - if (locationUrl.host == serverUrl.host) { - return RedirectInfo(server, location) - } + if (locationUrl.host == serverUrl.host) return RedirectInfo(server, location) + return null } @@ -274,7 +268,7 @@ public class RecommendedServerDiscovery constructor( } } .awaitAll() - allServers = allServers.plus(redirects.filterNotNull()) + allServers = allServers.plus(redirects.filterNotNull()).distinctBy { it.getAddress() } } discover(allServers, minimumScore) From 52ed8dd83b276adcde320741773014ddfda95f1b Mon Sep 17 00:00:00 2001 From: Allegra Date: Sat, 18 May 2024 15:29:29 -0400 Subject: [PATCH 5/7] Update generated files --- jellyfin-api-ktor/api/jellyfin-api-ktor.api | 4 ++-- jellyfin-api/api/jellyfin-api.api | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/jellyfin-api-ktor/api/jellyfin-api-ktor.api b/jellyfin-api-ktor/api/jellyfin-api-ktor.api index d26ccbef4..7faa0c5e3 100644 --- a/jellyfin-api-ktor/api/jellyfin-api-ktor.api +++ b/jellyfin-api-ktor/api/jellyfin-api-ktor.api @@ -7,8 +7,8 @@ public class org/jellyfin/sdk/api/ktor/KtorClient : org/jellyfin/sdk/api/client/ public fun getDeviceInfo ()Lorg/jellyfin/sdk/model/DeviceInfo; public fun getHttpClientOptions ()Lorg/jellyfin/sdk/api/client/HttpClientOptions; public fun getWebSocket ()Lorg/jellyfin/sdk/api/sockets/SocketApi; - public fun headRequest (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun request (Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun headRequest (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/ranges/IntRange;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun request (Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/ranges/IntRange;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun setAccessToken (Ljava/lang/String;)V public fun setBaseUrl (Ljava/lang/String;)V public fun setClientInfo (Lorg/jellyfin/sdk/model/ClientInfo;)V diff --git a/jellyfin-api/api/jellyfin-api.api b/jellyfin-api/api/jellyfin-api.api index ac68a069c..fc5d3ac6f 100644 --- a/jellyfin-api/api/jellyfin-api.api +++ b/jellyfin-api/api/jellyfin-api.api @@ -12,10 +12,10 @@ public abstract class org/jellyfin/sdk/api/client/ApiClient { public abstract fun getHttpClientOptions ()Lorg/jellyfin/sdk/api/client/HttpClientOptions; public final fun getOrCreateApi (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;)Lorg/jellyfin/sdk/api/operations/Api; public abstract fun getWebSocket ()Lorg/jellyfin/sdk/api/sockets/SocketApi; - public abstract fun headRequest (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun headRequest$default (Lorg/jellyfin/sdk/api/client/ApiClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public abstract fun request (Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun request$default (Lorg/jellyfin/sdk/api/client/ApiClient;Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun headRequest (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/ranges/IntRange;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun headRequest$default (Lorg/jellyfin/sdk/api/client/ApiClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/ranges/IntRange;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun request (Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/ranges/IntRange;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun request$default (Lorg/jellyfin/sdk/api/client/ApiClient;Lorg/jellyfin/sdk/api/client/HttpMethod;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Object;Lkotlin/ranges/IntRange;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun setAccessToken (Ljava/lang/String;)V public abstract fun setBaseUrl (Ljava/lang/String;)V public abstract fun setClientInfo (Lorg/jellyfin/sdk/model/ClientInfo;)V @@ -63,9 +63,10 @@ public final class org/jellyfin/sdk/api/client/HttpMethod : java/lang/Enum { } public final class org/jellyfin/sdk/api/client/RawResponse { - public fun (Lio/ktor/utils/io/ByteReadChannel;ILjava/util/Map;)V + public fun (Lio/ktor/utils/io/ByteReadChannel;ILio/ktor/http/Headers;)V + public final fun createHeadResponse ()Lorg/jellyfin/sdk/api/client/HeadResponse; public final fun getBody ()Lio/ktor/utils/io/ByteReadChannel; - public final fun getHeaders ()Ljava/util/Map; + public final fun getHeaders ()Lio/ktor/http/Headers; public final fun getStatus ()I } From 9c63063b124cb5588e0d2ea057fe7216e56f3682 Mon Sep 17 00:00:00 2001 From: Allegra Date: Sun, 19 May 2024 06:14:37 -0400 Subject: [PATCH 6/7] Refactor RecommendedServerDiscovery.kt to reduce code duplication --- .../client/exception/NoRedirectException.kt | 9 -- .../discovery/RecommendedServerDiscovery.kt | 89 ++++++++----------- 2 files changed, 38 insertions(+), 60 deletions(-) delete mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/exception/NoRedirectException.kt diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/exception/NoRedirectException.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/exception/NoRedirectException.kt deleted file mode 100644 index aa510a157..000000000 --- a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/exception/NoRedirectException.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.jellyfin.sdk.api.client.exception - -/** - * Exception for when we are looking for redirects and don't find one - */ -public class NoRedirectException( - public val status: Int, - cause: Throwable? = null, -) : ApiClientException("HTTP status is not a redirect. status: $status", cause) diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt index 578dcf2fd..00a23c327 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt @@ -11,12 +11,12 @@ import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import mu.KotlinLogging import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.HttpClientOptions import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.exception.InvalidContentException import org.jellyfin.sdk.api.client.exception.InvalidStatusException -import org.jellyfin.sdk.api.client.exception.NoRedirectException import org.jellyfin.sdk.api.client.exception.SecureConnectionException import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.extensions.systemApi @@ -37,6 +37,12 @@ public class RecommendedServerDiscovery( private const val SLOW_TIME_THRESHOLD = 1_500 private val HTTP_TIMEOUT = 3.5.seconds private const val MAX_SIMULTANEOUS_RETRIEVALS = 3 + private val httpClientOptions = HttpClientOptions( + followRedirects = false, + connectTimeout = HTTP_TIMEOUT, + requestTimeout = HTTP_TIMEOUT, + socketTimeout = HTTP_TIMEOUT, + ) } private data class SystemInfoResult( @@ -53,6 +59,23 @@ public class RecommendedServerDiscovery( fun getAddress() = redirectAddress ?: originalAddress } + private fun createClient(address: String): ApiClient = + jellyfin.createApi( + baseUrl = address, + httpClientOptions = httpClientOptions, + ) + + private fun handleApiClientException(err: ApiClientException, address: String): Result { + when (err) { + is SecureConnectionException -> logger.debug(err) { "SSL error when connecting to $address" } + is TimeoutException -> logger.debug(err) { "Timed out connecting to $address" } + is InvalidStatusException -> logger.debug(err) { "Received unexpected status ${err.status} from $address" } + is InvalidContentException -> logger.debug(err) { "Could not parse response from $address" } + else -> logger.debug(err) { "Unexpected error" } + } + return Result.failure(err) + } + @Suppress("MagicNumber") private fun assignScore(result: SystemInfoResult): RecommendedServerInfo { val systemInfo = result.systemInfo.getOrNull() @@ -137,34 +160,15 @@ public class RecommendedServerDiscovery( val address = server.getAddress() logger.info { "Requesting public system info for $address" } - val client = jellyfin.createApi( - baseUrl = address, - httpClientOptions = HttpClientOptions( - followRedirects = false, - connectTimeout = HTTP_TIMEOUT, - requestTimeout = HTTP_TIMEOUT, - socketTimeout = HTTP_TIMEOUT, - ), - ) + val client = createClient(address) val responseTimeStart = currentTimeMillis() - val info: Result> = try { val response = client.systemApi.getPublicSystemInfo() if (response.status == HTTP_OK) Result.success(response) else Result.failure(InvalidStatusException(response.status)) - } catch (err: TimeoutException) { - logger.debug(err) { "Could not connect to $address" } - Result.failure(err) - } catch (err: InvalidStatusException) { - logger.debug(err) { "Received unexpected status ${err.status} from $address" } - Result.failure(err) - } catch (err: InvalidContentException) { - logger.debug(err) { "Could not parse response from $address" } - Result.failure(err) } catch (err: ApiClientException) { - logger.debug(err) { "Unable to get response from $address" } - Result.failure(err) + handleApiClientException(err, address) } val responseTime = currentTimeMillis() - responseTimeStart @@ -175,55 +179,38 @@ public class RecommendedServerDiscovery( ) } - private suspend fun getRedirectInfo(server: String): RedirectInfo? { - logger.info { "Requesting header info for $server" } + private suspend fun getRedirectInfo(address: String): RedirectInfo? { + logger.info { "Requesting header info for $address" } - val client = jellyfin.createApi( - baseUrl = server, - httpClientOptions = HttpClientOptions( - followRedirects = false, - connectTimeout = HTTP_TIMEOUT, - requestTimeout = HTTP_TIMEOUT, - socketTimeout = HTTP_TIMEOUT, - ), - ) + val client = createClient(address) val headersResult = try { val response = client.headRequest() Result.success(response.headers) - } catch (err: TimeoutException) { - logger.debug(err) { "Could not connect to $server" } - Result.failure(err) - } catch (err: InvalidStatusException) { - logger.debug(err) { "Received non-redirect status ${err.status} from $server" } - Result.failure(NoRedirectException(err.status)) - } catch (err: InvalidContentException) { - logger.debug(err) { "Could not parse response from $server" } - Result.failure(err) } catch (err: ApiClientException) { - logger.debug(err) { "Unable to get response from $server" } - Result.failure(err) + handleApiClientException(err, address) } - // make sure there are headers + // get the headers or exit val headers = headersResult.getOrElse { return null } - // make sure there is a Location header and extract it from the map + // get the Location header or exit val location = headers[HttpHeaders.Location] ?: return null // only follow the redirect if on the same host val locationUrl = URLBuilder(location).build() - if (locationUrl.isRelativePath) return RedirectInfo(server, buildUrl(server, location)) - val serverUrl = URLBuilder(server).build() - if (locationUrl.host == serverUrl.host) return RedirectInfo(server, location) + if (locationUrl.isRelativePath) return RedirectInfo(address, buildUrl(address, location)) + val serverUrl = URLBuilder(address).build() + if (locationUrl.host == serverUrl.host) return RedirectInfo(address, location) + // host didn't match return null } /** * Test and score each server, including any discovered through redirects */ - private suspend fun discover( + private suspend fun testAndScoreServers( servers: Collection, minimumScore: RecommendedServerInfoScore, ): Collection = withContext(Dispatchers.IO) { @@ -271,6 +258,6 @@ public class RecommendedServerDiscovery( allServers = allServers.plus(redirects.filterNotNull()).distinctBy { it.getAddress() } } - discover(allServers, minimumScore) + testAndScoreServers(allServers, minimumScore) } } From 605260f4df5c533bbbeaa4e4af7d4c69f59b6f57 Mon Sep 17 00:00:00 2001 From: Allegra Date: Sun, 19 May 2024 06:22:47 -0400 Subject: [PATCH 7/7] Run apiDump gradle task --- jellyfin-api/api/jellyfin-api.api | 6 ------ 1 file changed, 6 deletions(-) diff --git a/jellyfin-api/api/jellyfin-api.api b/jellyfin-api/api/jellyfin-api.api index fc5d3ac6f..9ed812060 100644 --- a/jellyfin-api/api/jellyfin-api.api +++ b/jellyfin-api/api/jellyfin-api.api @@ -119,12 +119,6 @@ public final class org/jellyfin/sdk/api/client/exception/MissingPathVariableExce public final fun getPath ()Ljava/lang/String; } -public final class org/jellyfin/sdk/api/client/exception/NoRedirectException : org/jellyfin/sdk/api/client/exception/ApiClientException { - public fun (ILjava/lang/Throwable;)V - public synthetic fun (ILjava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getStatus ()I -} - public class org/jellyfin/sdk/api/client/exception/SecureConnectionException : org/jellyfin/sdk/api/client/exception/ApiClientException { public fun ()V public fun (Ljava/lang/String;Ljava/lang/Throwable;)V