diff --git a/jellyfin-api-ktor/api/jellyfin-api-ktor.api b/jellyfin-api-ktor/api/jellyfin-api-ktor.api index b298d2081..7faa0c5e3 100644 --- a/jellyfin-api-ktor/api/jellyfin-api-ktor.api +++ b/jellyfin-api-ktor/api/jellyfin-api-ktor.api @@ -7,7 +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 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-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..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 @@ -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 @@ -23,5 +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 c826f05b3..bc2d932b4 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,11 +14,10 @@ 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 +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 @@ -76,26 +75,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, @@ -126,9 +152,9 @@ public actual open class KtorClient actual constructor( } // Check HTTP status - if (!response.status.isSuccess()) throw InvalidStatusException(response.status.value) + if (response.status.value !in expectedResponseCodes) throw InvalidStatusException(response.status.value) // Return custom response instance - return RawResponse(response.bodyAsChannel(), response.status.value, response.headers.toMap()) + 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/api/jellyfin-api.api b/jellyfin-api/api/jellyfin-api.api index 0aa197422..cb7312998 100644 --- a/jellyfin-api/api/jellyfin-api.api +++ b/jellyfin-api/api/jellyfin-api.api @@ -12,8 +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 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 @@ -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 @@ -55,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 } 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..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,8 +91,16 @@ 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 = "", + pathParameters: Map = emptyMap(), + queryParameters: Map = emptyMap(), + expectedResponseCodes: IntRange = 300 until 400 + ): 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..e746b76f2 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HeadResponse.kt @@ -0,0 +1,8 @@ +package org.jellyfin.sdk.api.client + +import io.ktor.http.Headers + +public class HeadResponse( + public val status: Int, + public val headers: Headers +) 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/api/android/jellyfin-core.api b/jellyfin-core/api/android/jellyfin-core.api index bbcdca648..87252b3ec 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; } @@ -228,6 +233,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 1eb860956..2489a9436 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; } @@ -220,6 +225,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/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 85876271a..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 @@ -1,5 +1,8 @@ package org.jellyfin.sdk.discovery +import io.ktor.http.HttpHeaders +import io.ktor.http.URLBuilder +import io.ktor.http.isRelativePath import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -8,6 +11,7 @@ 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 @@ -16,6 +20,7 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException 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 @@ -23,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 { @@ -32,14 +37,45 @@ public class RecommendedServerDiscovery constructor( 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( - 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 + } + + 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() @@ -101,74 +137,91 @@ 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( - 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 return SystemInfoResult( - address = address, + server = server, systemInfo = info.map(Response::content), responseTime = responseTime, ) } + private suspend fun getRedirectInfo(address: String): RedirectInfo? { + logger.info { "Requesting header info for $address" } + + val client = createClient(address) + + val headersResult = try { + val response = client.headRequest() + Result.success(response.headers) + } catch (err: ApiClientException) { + handleApiClientException(err, address) + } + + // get the headers or exit + val headers = headersResult.getOrElse { return null } + + // 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(address, buildUrl(address, location)) + val serverUrl = URLBuilder(address).build() + if (locationUrl.host == serverUrl.host) return RedirectInfo(address, location) + + // host didn't match + 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 testAndScoreServers( + servers: Collection, minimumScore: RecommendedServerInfoScore, ): Collection = withContext(Dispatchers.IO) { + logger.info("Ranking ${servers.size} addresses") val semaphore = Semaphore(MAX_SIMULTANEOUS_RETRIEVALS) servers - .map { address -> + .map { server -> async { semaphore.withPermit { - getSystemInfoResult(address).let(::assignScore) + getSystemInfoResult(server).let(::assignScore) } } } @@ -178,4 +231,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()).distinctBy { it.getAddress() } + } + + testAndScoreServers(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 0a8b3b04c..f920bfa58 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 }