From 5dd9e24984be7a0c81351bc25a43aeba18c9ddcb Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sat, 26 Mar 2022 20:45:43 +0100 Subject: [PATCH] WebSocket API rewrite (#352) * WebSocket API rewrite * Review changes --- docs/websockets.md | 180 +++++++++++++ jellyfin-api/api/jellyfin-api.api | 115 +++++++- .../org/jellyfin/sdk/api/client/ApiClient.kt | 3 + .../org/jellyfin/sdk/api/client/KtorClient.kt | 6 + .../sdk/api/client/util/ApiSerializer.kt | 65 +++++ .../sockets/KtorSocketInstanceConnection.kt | 133 ++++++++++ .../sockets/ListenerRegistrationExtensions.kt | 117 +++++++++ .../api/sockets/SocketConnectionFactory.kt | 14 + .../sdk/api/sockets/SocketInstance.kt | 245 ++++++++++++++++++ .../api/sockets/SocketInstanceConnection.kt | 24 ++ .../sdk/api/sockets/SocketInstanceState.kt | 31 +++ .../sdk/api/sockets/data/Serializers.kt | 85 ++++++ .../sdk/api/sockets/data/SubscriptionType.kt | 31 +++ .../sdk/api/sockets/data/subscriptionTypes.kt | 24 ++ .../api/sockets/exception/SocketException.kt | 11 + .../exception/SocketStoppedException.kt | 3 + .../sdk/api/sockets/helper/KeepAliveHelper.kt | 34 +++ .../sdk/api/sockets/helper/ListenerHelper.kt | 40 +++ .../sdk/api/sockets/helper/ReconnectHelper.kt | 9 + .../api/sockets/listener/SocketListener.kt | 15 ++ .../listener/SocketListenerDefinition.kt | 12 + .../sockets/listener/SocketMessageReceiver.kt | 7 + .../org/jellyfin/sdk/api/client/KtorClient.kt | 6 + jellyfin-core/api/android/jellyfin-core.api | 12 +- jellyfin-core/api/jvm/jellyfin-core.api | 12 +- .../org/jellyfin/sdk/JellyfinOptions.kt | 5 + .../kotlin/org/jellyfin/sdk/Jellyfin.kt | 1 + .../org/jellyfin/sdk/JellyfinOptions.kt | 2 + .../org/jellyfin/sdk/util/ApiClientFactory.kt | 2 + .../org/jellyfin/sdk/JellyfinOptions.kt | 5 + jellyfin-model/api/jellyfin-model.api | 35 +++ .../sdk/model/socket/OutgoingSocketMessage.kt | 1 - .../model/socket/PeriodicListenerPeriod.kt | 9 +- .../model/socket/RawIncomingSocketMessage.kt | 21 ++ .../jellyfin/sample/cli/command/Observe.kt | 29 ++- 35 files changed, 1315 insertions(+), 29 deletions(-) create mode 100644 docs/websockets.md create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/KtorSocketInstanceConnection.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/ListenerRegistrationExtensions.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketConnectionFactory.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstance.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstanceConnection.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstanceState.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/Serializers.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/SubscriptionType.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/subscriptionTypes.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/exception/SocketException.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/exception/SocketStoppedException.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/KeepAliveHelper.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/ListenerHelper.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/ReconnectHelper.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketListener.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition.kt create mode 100644 jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver.kt create mode 100644 jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/RawIncomingSocketMessage.kt diff --git a/docs/websockets.md b/docs/websockets.md new file mode 100644 index 000000000..44191d4f1 --- /dev/null +++ b/docs/websockets.md @@ -0,0 +1,180 @@ +# Using WebSockets + +Added in v1.2.0, the new WebSocket implementation can be used to interact with the Jellyfin WebSocket server. This API +is not supported with the Java language because it heavily relies on coroutines and inline functions. + +Get started by creating a new authenticated API instance using the `createApi` function in the Jellyfin class. + +```kotlin +val api = jellyfin.createApi(baseUrl = "https://demo.jellyfin.org/stable/") +``` + +## Connecting + +The socket connection is managed by an "instance". You can have multiple of these instances at the same time. However, +it is recommended to use a single instance during the lifecycle of your application. Use the `ws()` +function from the ApiClient class to create a new instance. + +```kotlin +val instance = api.ws() +``` + +You can close an instance when it's no longer in use with the `stop()` function. + +```kotlin +instance.stop() +``` + +## Updating credentials + +An instance does not automatically refresh credentials. You'll need to manually refresh the instance when the access +token, server or device info change. Use the `updateCredentials()` function to apply these changes. The instance +automatically reconnects when required. + +```kotlin +instance.updateCredentials() +``` + +## Listen for messages + +Listeners are used to receive the various types of websocket messages. A connection is automatically started and/or +closed depending on the active listeners. Multiple helper functions can be used to register a listener. They all return +a `SocketListener` object that can be used to remove the listener later with the `removeListener()` function on the +instance or the `stop()` on the listener. + +## Listen for specific messages + +Use the `addListener()` function to create a listener that receives a single type. + +```kotlin +instance.addListener { message -> + // type of message is UserDataChangedMessage + println("Received a message: $message") +} +``` + +## Listen for all messages + +If you want to listen for all types of messages instead. Use the `addGlobalListener` function. + +```kotlin +instance.addGlobalListener { message -> + // type of message is IncomingSocketMessage + println("Received a message: $message") +} +``` + +## Listen for grouped message types + +Some incoming messages are used for multiple kinds of information. These are the general, play state and SyncPlay +commands. To filter the types of commands there are a few helper functions available. All of them support a "commands" +parameter to define the message types to receive. All types will be sent when the commands parameter is omitted. This is +the same behavior as using `addListener`. + +```kotlin +instance.addGeneralCommandsListener( + commands = setOf(GeneralCommandType.DISPLAY_MESSAGE) +) { message -> + // type of message is GeneralCommandMessage + println("Received a message: $message") +} + +instance.addPlayStateCommandsListener( + commands = setOf(PlaystateCommand.NEXT_TRACK, PlaystateCommand.PREVIOUS_TRACK) +) { message -> + // type of message is PlayStateMessage + println("Received a message: $message") +} + +instance.addSyncPlayCommandsListener( + commands = setOf(SendCommandType.PAUSE, SendCommandType.UNPAUSE) +) { message -> + // type of message is SyncPlayCommandMessage + println("Received a message: $message") +} +``` + +# Advanced listeners + +All previously mentioned functions to add listeners use the `addListenerDefinition` function under the hood. This +function is not recommended being used directly. Use the other functions instead. The function receives a listener +definition. + +An example for listening to both LibraryChangedMessage and UserDataChangedMessage messages: + +```kotlin +instance.addListenerDefinition( + SocketListenerDefinition( + subscribesTo = emptySet(), + filterTypes = setOf(LibraryChangedMessage::class, UserDataChangedMessage::class), + stopOnCredentialsChange = false, + listener = { message -> + // type of message is IncomingSocketMessage + println("Received a message: $message") + } + ) +) +``` + +## Sending messages + +The Jellyfin server uses HTTP endpoints, mostly in the SessionApi, to manipulate state. The only messages send by a +client are to enable subscriptions. These subscriptions are automatically managed by the SDK. The `publish()` function +can still be used if you need to send your own messages. The function receives a `OutgoingSocketMessage` type and sends +it to the server. + +```kotlin +instance.publish(SessionsStartMessage()) +``` + +> **Note**: Do not send start and stop messages manually. This can confuse the SDK and cause unknown behavior. + +## Message Types + +The following messages types are supported in the SDK. + +### Incoming + +- GeneralCommandMessage +- UserDataChangedMessage +- SessionsMessage +- PlayMessage +- SyncPlayCommandMessage +- SyncPlayGroupUpdateMessage +- PlayStateMessage +- RestartRequiredMessage +- ServerShuttingDownMessage +- ServerRestartingMessage +- LibraryChangedMessage +- UserDeletedMessage +- UserUpdatedMessage +- SeriesTimerCreatedMessage +- TimerCreatedMessage +- SeriesTimerCancelledMessage +- TimerCancelledMessage +- RefreshProgressMessage +- ScheduledTaskEndedMessage +- PackageInstallationCancelledMessage +- PackageInstallationFailedMessage +- PackageInstallationCompletedMessage +- PackageInstallingMessage +- PackageUninstalledMessage +- ActivityLogEntryMessage +- ScheduledTasksInfoMessage + +### Outgoing + +- ActivityLogEntryStartMessage and ActivityLogEntryStopMessage +- SessionsStartMessage and SessionsStopMessage +- ScheduledTasksInfoStartMessage and ScheduledTasksInfoStopMessage + +## Sample usage + +- The [observe] command in the [kotlin-cli] sample uses websockets to listen for messages. +- The [jellyfin-androidtv] app uses websockets for remote media control and realtime data updates. + +[observe]: /samples/kotlin-cli/src/main/kotlin/org/jellyfin/sample/cli/command/Observe.kt + +[kotlin-cli]: /samples/kotlin-cli/ + +[jellyfin-androidtv]: https://github.com/jellyfin/jellyfin-androidtv diff --git a/jellyfin-api/api/jellyfin-api.api b/jellyfin-api/api/jellyfin-api.api index aa7df9d6c..e9f21ed4d 100644 --- a/jellyfin-api/api/jellyfin-api.api +++ b/jellyfin-api/api/jellyfin-api.api @@ -18,6 +18,7 @@ public abstract class org/jellyfin/sdk/api/client/ApiClient { public abstract fun setClientInfo (Lorg/jellyfin/sdk/model/ClientInfo;)V public abstract fun setDeviceInfo (Lorg/jellyfin/sdk/model/DeviceInfo;)V public abstract fun setUserId (Ljava/util/UUID;)V + public abstract fun ws ()Lorg/jellyfin/sdk/api/sockets/SocketInstance; } public final class org/jellyfin/sdk/api/client/ApiClient$Companion { @@ -51,8 +52,8 @@ public final class org/jellyfin/sdk/api/client/HttpMethod : java/lang/Enum { } public class org/jellyfin/sdk/api/client/KtorClient : org/jellyfin/sdk/api/client/ApiClient { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/UUID;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/api/client/HttpClientOptions;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/UUID;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/api/client/HttpClientOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/UUID;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/api/client/HttpClientOptions;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/UUID;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/api/client/HttpClientOptions;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getAccessToken ()Ljava/lang/String; public fun getBaseUrl ()Ljava/lang/String; public fun getClientInfo ()Lorg/jellyfin/sdk/model/ClientInfo; @@ -65,6 +66,7 @@ public class org/jellyfin/sdk/api/client/KtorClient : org/jellyfin/sdk/api/clien public fun setClientInfo (Lorg/jellyfin/sdk/model/ClientInfo;)V public fun setDeviceInfo (Lorg/jellyfin/sdk/model/DeviceInfo;)V public fun setUserId (Ljava/util/UUID;)V + public fun ws ()Lorg/jellyfin/sdk/api/sockets/SocketInstance; } public final class org/jellyfin/sdk/api/client/RawResponse { @@ -208,8 +210,10 @@ public final class org/jellyfin/sdk/api/client/extensions/UserApiExtensionsKt { public final class org/jellyfin/sdk/api/client/util/ApiSerializer { public static final field INSTANCE Lorg/jellyfin/sdk/api/client/util/ApiSerializer; + public final fun decodeSocketMessage (Ljava/lang/String;)Lorg/jellyfin/sdk/model/socket/IncomingSocketMessage; public final fun encodeRequestBody (Ljava/lang/Object;)Ljava/lang/String; public static synthetic fun encodeRequestBody$default (Lorg/jellyfin/sdk/api/client/util/ApiSerializer;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/String; + public final fun encodeSocketMessage (Lorg/jellyfin/sdk/model/socket/OutgoingSocketMessage;)Ljava/lang/String; public final fun getJson ()Lkotlinx/serialization/json/Json; } @@ -1168,6 +1172,52 @@ public final class org/jellyfin/sdk/api/operations/YearsApi : org/jellyfin/sdk/a public static synthetic fun getYears$default (Lorg/jellyfin/sdk/api/operations/YearsApi;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Collection;Ljava/util/UUID;Ljava/util/Collection;Ljava/util/Collection;Ljava/util/Collection;Ljava/util/Collection;Ljava/util/Collection;Ljava/lang/Boolean;Ljava/lang/Integer;Ljava/util/Collection;Ljava/util/UUID;Ljava/lang/Boolean;Ljava/lang/Boolean;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public final class org/jellyfin/sdk/api/sockets/KtorSocketInstanceConnection : org/jellyfin/sdk/api/sockets/SocketInstanceConnection { + public fun (Lorg/jellyfin/sdk/api/client/HttpClientOptions;Lkotlinx/coroutines/channels/Channel;Lkotlinx/coroutines/channels/Channel;Lkotlin/coroutines/CoroutineContext;)V + public fun connect (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun disconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/jellyfin/sdk/api/sockets/ListenerRegistrationExtensionsKt { + public static final fun addGeneralCommandsListener (Lorg/jellyfin/sdk/api/sockets/SocketInstance;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; + public static synthetic fun addGeneralCommandsListener$default (Lorg/jellyfin/sdk/api/sockets/SocketInstance;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;ILjava/lang/Object;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; + public static final fun addGlobalListener (Lorg/jellyfin/sdk/api/sockets/SocketInstance;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; + public static synthetic fun addGlobalListener$default (Lorg/jellyfin/sdk/api/sockets/SocketInstance;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;ILjava/lang/Object;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; + public static final fun addPlayStateCommandsListener (Lorg/jellyfin/sdk/api/sockets/SocketInstance;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; + public static synthetic fun addPlayStateCommandsListener$default (Lorg/jellyfin/sdk/api/sockets/SocketInstance;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;ILjava/lang/Object;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; + public static final fun addSyncPlayCommandsListener (Lorg/jellyfin/sdk/api/sockets/SocketInstance;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; + public static synthetic fun addSyncPlayCommandsListener$default (Lorg/jellyfin/sdk/api/sockets/SocketInstance;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;ILjava/lang/Object;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; +} + +public abstract interface class org/jellyfin/sdk/api/sockets/SocketConnectionFactory { + public abstract fun create (Lorg/jellyfin/sdk/api/client/HttpClientOptions;Lkotlinx/coroutines/channels/Channel;Lkotlinx/coroutines/channels/Channel;Lkotlin/coroutines/CoroutineContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/jellyfin/sdk/api/sockets/SocketInstance { + public final fun addListenerDefinition (Lorg/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListener; + public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun publish (Lorg/jellyfin/sdk/model/socket/OutgoingSocketMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun reconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun removeListener (Lorg/jellyfin/sdk/api/sockets/listener/SocketListener;)V + public final fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun updateCredentials (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/jellyfin/sdk/api/sockets/SocketInstanceConnection { + public abstract fun connect (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun disconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/jellyfin/sdk/api/sockets/SocketInstanceState : java/lang/Enum { + public static final field CONNECTED Lorg/jellyfin/sdk/api/sockets/SocketInstanceState; + public static final field CONNECTING Lorg/jellyfin/sdk/api/sockets/SocketInstanceState; + public static final field DISCONNECTED Lorg/jellyfin/sdk/api/sockets/SocketInstanceState; + public static final field ERROR Lorg/jellyfin/sdk/api/sockets/SocketInstanceState; + public static final field STOPPED Lorg/jellyfin/sdk/api/sockets/SocketInstanceState; + public static fun valueOf (Ljava/lang/String;)Lorg/jellyfin/sdk/api/sockets/SocketInstanceState; + public static fun values ()[Lorg/jellyfin/sdk/api/sockets/SocketInstanceState; +} + public final class org/jellyfin/sdk/api/sockets/SocketSubscription { public fun (Lorg/jellyfin/sdk/api/sockets/WebSocketApi;Lkotlin/jvm/functions/Function2;)V public final fun cancel ()V @@ -1182,3 +1232,64 @@ public final class org/jellyfin/sdk/api/sockets/WebSocketApi : org/jellyfin/sdk/ public final fun subscribe (Lkotlin/jvm/functions/Function2;)Lorg/jellyfin/sdk/api/sockets/SocketSubscription; } +public final class org/jellyfin/sdk/api/sockets/data/SubscriptionType { + public fun (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)V + public final fun component1 ()Lkotlin/reflect/KClass; + public final fun component2 ()Lkotlin/jvm/functions/Function1; + public final fun component3 ()Lkotlin/jvm/functions/Function0; + public final fun copy (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lorg/jellyfin/sdk/api/sockets/data/SubscriptionType; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/api/sockets/data/SubscriptionType;Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/jellyfin/sdk/api/sockets/data/SubscriptionType; + public fun equals (Ljava/lang/Object;)Z + public final fun getCreateStartMessage ()Lkotlin/jvm/functions/Function1; + public final fun getCreateStopMessage ()Lkotlin/jvm/functions/Function0; + public final fun getMessageType ()Lkotlin/reflect/KClass; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/jellyfin/sdk/api/sockets/data/SubscriptionTypeKt { + public static final fun getSubscriptionType (Lkotlin/reflect/KClass;)Lorg/jellyfin/sdk/api/sockets/data/SubscriptionType; +} + +public final class org/jellyfin/sdk/api/sockets/data/SubscriptionTypesKt { + public static final fun getSUBSCRIPTION_TYPES ()Ljava/util/Set; +} + +public class org/jellyfin/sdk/api/sockets/exception/SocketException : org/jellyfin/sdk/api/client/exception/ApiClientException { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class org/jellyfin/sdk/api/sockets/exception/SocketStoppedException : org/jellyfin/sdk/api/sockets/exception/SocketException { + public fun ()V +} + +public final class org/jellyfin/sdk/api/sockets/listener/SocketListener { + public fun (Lorg/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition;Lorg/jellyfin/sdk/api/sockets/SocketInstance;)V + public final fun getDefinition ()Lorg/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition; + public final fun getInstance ()Lorg/jellyfin/sdk/api/sockets/SocketInstance; + public final fun stop ()V +} + +public final class org/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition { + public fun (Ljava/util/Set;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;)V + public final fun component1 ()Ljava/util/Set; + public final fun component2 ()Ljava/util/Set; + public final fun component3 ()Z + public final fun component4 ()Lorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver; + public final fun copy (Ljava/util/Set;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition;Ljava/util/Set;Ljava/util/Set;ZLorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver;ILjava/lang/Object;)Lorg/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition; + public fun equals (Ljava/lang/Object;)Z + public final fun getFilterTypes ()Ljava/util/Set; + public final fun getListener ()Lorg/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver; + public final fun getStopOnCredentialsChange ()Z + public final fun getSubscribesTo ()Ljava/util/Set; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver { + public abstract fun onReceive (Lorg/jellyfin/sdk/model/socket/IncomingSocketMessage;)V +} + 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 a442f37f1..17a415cb1 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 @@ -3,6 +3,7 @@ package org.jellyfin.sdk.api.client import org.jellyfin.sdk.api.client.exception.MissingBaseUrlException import org.jellyfin.sdk.api.client.util.UrlBuilder import org.jellyfin.sdk.api.operations.Api +import org.jellyfin.sdk.api.sockets.SocketInstance import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.UUID @@ -79,6 +80,8 @@ public abstract class ApiClient { requestBody: Any? = null, ): RawResponse + public abstract fun ws(): SocketInstance + private val apiInstances = mutableMapOf, Api>() @Suppress("UNCHECKED_CAST") diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/KtorClient.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/KtorClient.kt index 6781ac21e..9a4a98f79 100644 --- a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/KtorClient.kt +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/KtorClient.kt @@ -1,9 +1,12 @@ package org.jellyfin.sdk.api.client +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory +import org.jellyfin.sdk.api.sockets.SocketInstance import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.UUID +@Suppress("LongParameterList") public expect open class KtorClient( baseUrl: String? = null, accessToken: String? = null, @@ -11,6 +14,7 @@ public expect open class KtorClient( clientInfo: ClientInfo, deviceInfo: DeviceInfo, httpClientOptions: HttpClientOptions, + socketConnectionFactory: SocketConnectionFactory, ) : ApiClient { public override suspend fun request( method: HttpMethod, @@ -19,4 +23,6 @@ public expect open class KtorClient( queryParameters: Map, requestBody: Any?, ): RawResponse + + public override fun ws(): SocketInstance } diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/util/ApiSerializer.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/util/ApiSerializer.kt index 6bd987fc1..5a104941c 100644 --- a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/util/ApiSerializer.kt +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/util/ApiSerializer.kt @@ -2,14 +2,30 @@ package org.jellyfin.sdk.api.client.util import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readRemaining +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.serializer +import org.jellyfin.sdk.api.sockets.data.serializer +import org.jellyfin.sdk.model.api.SessionMessageType +import org.jellyfin.sdk.model.socket.IncomingSocketMessage +import org.jellyfin.sdk.model.socket.OutgoingSocketMessage +import org.jellyfin.sdk.model.socket.RawIncomingSocketMessage @OptIn(InternalSerializationApi::class) public object ApiSerializer { + private const val SOCKET_MESSAGE_DATA = "Data" + private const val SOCKET_MESSAGE_MESSAGE_ID = "MessageId" + private const val SOCKET_MESSAGE_MESSAGE_TYPE = "MessageType" + public val json: Json = Json { isLenient = false ignoreUnknownKeys = true @@ -17,6 +33,10 @@ public object ApiSerializer { useArrayPolymorphism = false } + private val jsonSocketMessage: Json = Json(json) { + encodeDefaults = true + } + public fun encodeRequestBody(requestBody: Any? = null): String? { if (requestBody == null) return null @@ -28,4 +48,49 @@ public object ApiSerializer { T::class == ByteReadChannel::class -> responseBody as T else -> json.decodeFromString(responseBody.readRemaining().readText()) } + + @OptIn(ExperimentalSerializationApi::class) + public fun encodeSocketMessage(message: OutgoingSocketMessage): String { + // Serialize with default serializer + val serializer = message::class.serializer() as KSerializer + val jsonObject = jsonSocketMessage.encodeToJsonElement(serializer, message).jsonObject + + // Extract type name + val messageType = serializer.descriptor.serialName + + // Create actual message + return jsonSocketMessage.encodeToString(buildJsonObject { + // Set type property + put(SOCKET_MESSAGE_MESSAGE_TYPE, messageType) + + // Set data property + val data = jsonObject[SOCKET_MESSAGE_DATA] + if (data != null) put(SOCKET_MESSAGE_DATA, data) + else putJsonObject(SOCKET_MESSAGE_DATA) { + jsonObject.entries + .filterNot { (key, _) -> key == SOCKET_MESSAGE_MESSAGE_TYPE } + .forEach { (key, value) -> put(key, value) } + } + }) + } + + public fun decodeSocketMessage(message: String): IncomingSocketMessage? { + val rawMessage = jsonSocketMessage.decodeFromString(message) + + // The KeepAliveMessage type is used for both sending and receiving + // the SDK doesn't support this behavior, so we need to ignore + // it for now. It's not that useful for a client anyway. + if (rawMessage.type == SessionMessageType.KEEP_ALIVE) return null + + // Modify JSON to flatten the Data object + val modifiedJson = buildJsonObject { + put(SOCKET_MESSAGE_MESSAGE_ID, rawMessage.id.toString()) + + val data = rawMessage.data + if (data is JsonObject) data.entries.forEach { (key, value) -> put(key, value) } + if (data != null) put(SOCKET_MESSAGE_DATA, data) + } + + return jsonSocketMessage.decodeFromJsonElement(rawMessage.type.serializer, modifiedJson) as? IncomingSocketMessage + } } diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/KtorSocketInstanceConnection.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/KtorSocketInstanceConnection.kt new file mode 100644 index 000000000..48d262e8a --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/KtorSocketInstanceConnection.kt @@ -0,0 +1,133 @@ +package org.jellyfin.sdk.api.sockets + +import io.ktor.client.HttpClient +import io.ktor.client.features.HttpTimeout +import io.ktor.client.features.websocket.ClientWebSocketSession +import io.ktor.client.features.websocket.DefaultClientWebSocketSession +import io.ktor.client.features.websocket.WebSockets +import io.ktor.client.features.websocket.webSocketSession +import io.ktor.client.request.url +import io.ktor.http.cio.websocket.CloseReason +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.close +import io.ktor.http.cio.websocket.readText +import io.ktor.utils.io.core.EOFException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.exception.ApiClientException +import kotlin.coroutines.CoroutineContext + +private val logger = KotlinLogging.logger {} + +public class KtorSocketInstanceConnection( + clientOptions: HttpClientOptions, + private val incomingMessageChannel: Channel, + private val outgoingMessageChannel: Channel, + context: CoroutineContext, +) : SocketInstanceConnection { + private val coroutineScope = CoroutineScope(context) + + private val client by lazy { + HttpClient { + followRedirects = clientOptions.followRedirects + + install(HttpTimeout) { + connectTimeoutMillis = clientOptions.connectTimeout + socketTimeoutMillis = clientOptions.socketTimeout + // ignore clientOptions.requestTimeout to prevent the socket from closing + requestTimeoutMillis = null + } + + install(WebSockets) + } + } + + private var connection: ClientWebSocketSession? = null + + public override suspend fun connect(url: String): Boolean { + logger.info { "Connecting to $url" } + + // Make sure there is no existing connection + connection?.close() + + // Create new connection + return try { + connection = client.webSocketSession { + url(url) + }.apply { + logger.info { "Connected" } + + // Attach message channels + attach(this) + } + + true + } catch (err: Throwable) { + logger.error(err) { "Not connected" } + + false + } + } + + private fun attach(session: DefaultClientWebSocketSession) { + logger.debug { "Attaching message channels" } + + // Receive messages + coroutineScope.launch { + try { + for (frame in session.incoming) { + if (frame !is Frame.Text) continue + + val message = frame.readText() + logger.info { "Receiving (raw) message $message" } + incomingMessageChannel.send(message) + } + } catch (err: EOFException) { + // Ignored: propagated by Ktor to the closeReason + logger.debug(err) { "Socket closed with EOFException" } + } catch (err: Throwable) { + throw ApiClientException("Unknown exception while receiving WebSocket message", err) + } finally { + // Channel closed, wait for the reason to be set + logger.info { "Incoming channel closed, cancelling connection" } + val reason = try { + session.closeReason.await() + } catch (err: EOFException) { + CloseReason(CloseReason.Codes.INTERNAL_ERROR, "EOFException (abrupt connection issue)") + } + logger.info { "Close reason was $reason" } + // TODO send disconnect notification to SocketInstance for retry policy + disconnect() + } + } + + // Send messages + coroutineScope.launch { + for (message in outgoingMessageChannel) { + logger.info { "Sending (raw) message $message" } + + try { + connection?.outgoing?.send(Frame.Text(message)) + } catch (err: EOFException) { + // Ignored: dealt with by incoming message job + logger.debug(err) { "Socket closed with EOFException" } + } catch (err: Throwable) { + throw ApiClientException("Unknown exception while sending WebSocket message", err) + } + } + } + } + + override suspend fun disconnect() { + logger.info { "Disconnecting" } + + connection?.close() + connection = null + + coroutineScope.cancel() + } +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/ListenerRegistrationExtensions.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/ListenerRegistrationExtensions.kt new file mode 100644 index 000000000..8298cea51 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/ListenerRegistrationExtensions.kt @@ -0,0 +1,117 @@ +package org.jellyfin.sdk.api.sockets + +import org.jellyfin.sdk.api.sockets.data.SUBSCRIPTION_TYPES +import org.jellyfin.sdk.api.sockets.data.subscriptionType +import org.jellyfin.sdk.api.sockets.listener.SocketListener +import org.jellyfin.sdk.api.sockets.listener.SocketListenerDefinition +import org.jellyfin.sdk.api.sockets.listener.SocketMessageReceiver +import org.jellyfin.sdk.model.api.GeneralCommandType +import org.jellyfin.sdk.model.api.PlaystateCommand +import org.jellyfin.sdk.model.api.SendCommandType +import org.jellyfin.sdk.model.socket.GeneralCommandMessage +import org.jellyfin.sdk.model.socket.IncomingSocketMessage +import org.jellyfin.sdk.model.socket.PlayStateMessage +import org.jellyfin.sdk.model.socket.SyncPlayCommandMessage + +/** + * Add a listener that listens to all message types. + * If you want to listen for specific messages you can use [addListener] or [addGeneralCommandsListener] instead. + */ +@Suppress("NOTHING_TO_INLINE") +public inline fun SocketInstance.addGlobalListener( + stopOnCredentialsChange: Boolean = false, + listener: SocketMessageReceiver, +): SocketListener { + val definition = SocketListenerDefinition( + subscribesTo = SUBSCRIPTION_TYPES, + filterTypes = setOf(IncomingSocketMessage::class), + stopOnCredentialsChange = stopOnCredentialsChange, + listener = listener + ) + return addListenerDefinition(definition) +} + +/** + * Add a listener that listens to a specific message type. + */ +public inline fun SocketInstance.addListener( + stopOnCredentialsChange: Boolean = false, + listener: SocketMessageReceiver, +): SocketListener { + val type = T::class + val definition = SocketListenerDefinition( + subscribesTo = type.subscriptionType?.let { setOf(it) }.orEmpty(), + filterTypes = setOf(type), + stopOnCredentialsChange = stopOnCredentialsChange, + listener = { message -> + if (message is T) listener.onReceive(message) + } + ) + return addListenerDefinition(definition) +} + +/** + * Add a listener that listens to certain [GeneralCommandType] entries in the [GeneralCommandMessage]. + */ +@Suppress("NOTHING_TO_INLINE") +public inline fun SocketInstance.addGeneralCommandsListener( + commands: Set = GeneralCommandType.values().toSet(), + stopOnCredentialsChange: Boolean = false, + listener: SocketMessageReceiver, +): SocketListener { + val definition = SocketListenerDefinition( + subscribesTo = emptySet(), + filterTypes = setOf(GeneralCommandMessage::class), + stopOnCredentialsChange = stopOnCredentialsChange, + listener = { message -> + if (message is GeneralCommandMessage && message.command in commands) { + listener.onReceive(message) + } + } + ) + return addListenerDefinition(definition) +} + +/** + * Add a listener that listens to certain [PlaystateCommand] entries in the [PlayStateMessage]. + */ +@Suppress("NOTHING_TO_INLINE") +public inline fun SocketInstance.addPlayStateCommandsListener( + commands: Set = PlaystateCommand.values().toSet(), + stopOnCredentialsChange: Boolean = false, + listener: SocketMessageReceiver, +): SocketListener { + val definition = SocketListenerDefinition( + subscribesTo = emptySet(), + filterTypes = setOf(PlayStateMessage::class), + stopOnCredentialsChange = stopOnCredentialsChange, + listener = { message -> + if (message is PlayStateMessage && message.request.command in commands) { + listener.onReceive(message) + } + } + ) + return addListenerDefinition(definition) +} + +/** + * Add a listener that listens to certain [SendCommandType] entries in the [SyncPlayCommandMessage]. + */ +@Suppress("NOTHING_TO_INLINE") +public inline fun SocketInstance.addSyncPlayCommandsListener( + commands: Set = SendCommandType.values().toSet(), + stopOnCredentialsChange: Boolean = false, + listener: SocketMessageReceiver, +): SocketListener { + val definition = SocketListenerDefinition( + subscribesTo = emptySet(), + filterTypes = setOf(SyncPlayCommandMessage::class), + stopOnCredentialsChange = stopOnCredentialsChange, + listener = { message -> + if (message is SyncPlayCommandMessage && message.command.command in commands) { + listener.onReceive(message) + } + } + ) + return addListenerDefinition(definition) +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketConnectionFactory.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketConnectionFactory.kt new file mode 100644 index 000000000..0871fd58e --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketConnectionFactory.kt @@ -0,0 +1,14 @@ +package org.jellyfin.sdk.api.sockets + +import kotlinx.coroutines.channels.Channel +import org.jellyfin.sdk.api.client.HttpClientOptions +import kotlin.coroutines.CoroutineContext + +public fun interface SocketConnectionFactory { + public suspend fun create( + clientOptions: HttpClientOptions, + incomingMessageChannel: Channel, + outgoingMessageChannel: Channel, + coroutineContext: CoroutineContext, + ): SocketInstanceConnection +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstance.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstance.kt new file mode 100644 index 000000000..ffd678b8d --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstance.kt @@ -0,0 +1,245 @@ +package org.jellyfin.sdk.api.sockets + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.ApiSerializer +import org.jellyfin.sdk.api.client.util.UrlBuilder +import org.jellyfin.sdk.api.sockets.exception.SocketStoppedException +import org.jellyfin.sdk.api.sockets.helper.KeepAliveHelper +import org.jellyfin.sdk.api.sockets.helper.ListenerHelper +import org.jellyfin.sdk.api.sockets.listener.SocketListener +import org.jellyfin.sdk.api.sockets.listener.SocketListenerDefinition +import org.jellyfin.sdk.model.socket.ForceKeepAliveMessage +import org.jellyfin.sdk.model.socket.OutgoingSocketMessage +import org.jellyfin.sdk.model.socket.PeriodicListenerPeriod +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds + +private val logger = KotlinLogging.logger {} + +public class SocketInstance internal constructor( + private val api: ApiClient, + private val socketConnectionFactory: SocketConnectionFactory, + context: CoroutineContext = Dispatchers.Default, +) { + private companion object { + private const val SOCKET_URL = "/socket" + private const val QUERY_ACCESS_TOKEN = "api_key" + private const val QUERY_DEVICE_ID = "deviceId" + private const val MESSAGE_INTERVAL = 1_000L // second + } + + private val coroutineContext = context + SupervisorJob() + private val coroutineScope = CoroutineScope(coroutineContext) + + private var credentialsChanged = false + private var baseUrl = api.baseUrl + private var accessToken = api.accessToken + private var deviceInfo = api.deviceInfo + + private val _state = MutableStateFlow(SocketInstanceState.DISCONNECTED) + public val state: StateFlow = _state + + private var connection: SocketInstanceConnection? = null + private val incomingMessages = Channel() + private val outgoingMessages = Channel() + + private val listenerHelper = ListenerHelper() + private val keepAliveHelper = KeepAliveHelper(coroutineScope) + + private var messageForwardJob: Job? = null + + /** + * Update the server url and access token. + * Takes the latest values from the ApiClient and reconnects if the values changed. + */ + public suspend fun updateCredentials(): Boolean { + logger.debug { "Credential update requested" } + + if (state.value == SocketInstanceState.ERROR) { + logger.info { "Unable to update credentials: state is error" } + return false + } + + if (api.baseUrl == null) { + logger.info { "Unable to update credentials: api.baseUrl is null. Disconnecting." } + connection?.disconnect() + _state.value = SocketInstanceState.ERROR + return false + } + + val newBaseUrl = requireNotNull(api.baseUrl) + val newAccessToken = api.accessToken + val newDeviceInfo = api.deviceInfo + + // No changes - do nothing + if (baseUrl == newBaseUrl && accessToken == newAccessToken && deviceInfo == newDeviceInfo) { + logger.debug { "Unable to update credentials: credentials did not change" } + return false + } + + logger.info { "Updating credentials for $baseUrl" } + + baseUrl = newBaseUrl + accessToken = newAccessToken + deviceInfo = newDeviceInfo + + credentialsChanged = true + + return updateConnectionState() + } + + private suspend fun updateConnectionState(): Boolean { + // Don't update when an error occurred, client should manually call [reconnect] + if (state.value == SocketInstanceState.ERROR) { + logger.info { "Unable to update connection state: state is error" } + return false + } + + // Remove listeners that don't want credential changes + if (credentialsChanged) listenerHelper.reportCredentialChangedReconnect() + + val connected = connection != null // TODO make smarter (handle disconnect etc.) + + when { + // Stop if there's no listeners + listenerHelper.listeners.isEmpty() -> connection?.disconnect() + // Reconnect when credentials changed or not connected + credentialsChanged || !connected -> reconnect() + // Update subscriptions when not reconnecting or disconnecting + else -> updateSubscriptions() + } + + // Make sure credentials changed is set to false + credentialsChanged = false + + return true + } + + private suspend fun updateSubscriptions() { + val subscriptions = listenerHelper.subscriptions + + // Remove subscriptions not in use anymore + for (subscription in listenerHelper.activeSubscriptions.reversed()) { + if (!subscriptions.contains(subscription)) { + logger.info { "Removing subscription for ${subscription.messageType.simpleName}" } + publish(subscription.createStopMessage()) + listenerHelper.activeSubscriptions.remove(subscription) + } + } + + // Add new subscriptions + // Period is not configurable as it is barely used by the server + // The initialDelay is never used in fact + val period = PeriodicListenerPeriod(0, MESSAGE_INTERVAL) + for (subscription in subscriptions) { + if (!listenerHelper.activeSubscriptions.contains(subscription)) { + logger.info { "Adding subscription for ${subscription.messageType.simpleName}" } + publish(subscription.createStartMessage(period)) + listenerHelper.activeSubscriptions.add(subscription) + } + } + } + + public suspend fun reconnect() { + logger.debug { "Reconnect requested" } + + // Already connecting + if (state.value == SocketInstanceState.CONNECTING) return + + // Explicitly stopped + if (state.value == SocketInstanceState.STOPPED) throw SocketStoppedException() + + logger.info { "Reconnecting" } + _state.value = SocketInstanceState.CONNECTING + + connection?.disconnect() + + connection = socketConnectionFactory.create( + api.httpClientOptions, + incomingMessages, + outgoingMessages, + coroutineContext, + ).apply { + val connected = connect(UrlBuilder.buildUrl( + baseUrl = requireNotNull(baseUrl), + pathTemplate = SOCKET_URL, + queryParameters = mapOf( + QUERY_DEVICE_ID to deviceInfo.id, + QUERY_ACCESS_TOKEN to accessToken, + ) + ).replace(Regex("^http"), "ws")) + + if (connected) { + messageForwardJob?.cancel() + messageForwardJob = coroutineScope.launch { + for (message in incomingMessages) forwardMessage(message) + } + listenerHelper.activeSubscriptions.clear() + updateSubscriptions() + _state.value = SocketInstanceState.CONNECTED + } else { + _state.value = SocketInstanceState.ERROR + // TODO retry: failed connecting + } + } + } + + /** + * Stops the connection and removes all listeners. The instance cannot be started again. + * Calling [reconnect] after this function is not allowed. + */ + public suspend fun stop() { + logger.info { "Stopping socket instance" } + + connection?.disconnect() + messageForwardJob?.cancel() + listenerHelper.reset() + incomingMessages.close() + outgoingMessages.close() + coroutineScope.cancel() + + _state.value = SocketInstanceState.STOPPED + } + + /** + * Add a listener. This is the underlying function for [addGlobalListener], [addListener] or + * [addGeneralCommandsListener]. + */ + public fun addListenerDefinition(definition: SocketListenerDefinition): SocketListener { + val listener = listenerHelper.addListenerDefinition(this, definition) + coroutineScope.launch { updateConnectionState() } + return listener + } + + /** + * Removes a listener. + */ + public fun removeListener(listener: SocketListener) { + listenerHelper.removeListener(listener) + coroutineScope.launch { updateConnectionState() } + } + + private fun forwardMessage(rawMessage: String) { + val message = ApiSerializer.decodeSocketMessage(rawMessage) ?: return + + if (message is ForceKeepAliveMessage) keepAliveHelper.reset(this, message.value.seconds) + else listenerHelper.forwardMessage(message) + } + + /** + * Send a message to the server. This function is normally not used by the application. + */ + public suspend fun publish(message: OutgoingSocketMessage) { + outgoingMessages.send(ApiSerializer.encodeSocketMessage(message)) + } +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstanceConnection.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstanceConnection.kt new file mode 100644 index 000000000..93f3ded83 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstanceConnection.kt @@ -0,0 +1,24 @@ +package org.jellyfin.sdk.api.sockets + +/** + * Reusable WebSocket connection. Constructed using [SocketConnectionFactory]. + */ +public interface SocketInstanceConnection { + /** + * Connect to [url]. If there is an existing connection it will be automatically closed. After the connection is + * initialized the messageListener supplied via the factory will be called until [disconnect] is called or the + * connection is closed by other means (server closed, network issues, new connect call etc). + * + * @return true when connected, false when connection failed. + * + * @see disconnect + */ + public suspend fun connect(url: String): Boolean + + /** + * Disconnect the connection. Will do nothing when there is no connection. + * + * @see connect + */ + public suspend fun disconnect() +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstanceState.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstanceState.kt new file mode 100644 index 000000000..19e644615 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/SocketInstanceState.kt @@ -0,0 +1,31 @@ +package org.jellyfin.sdk.api.sockets + +/** + * Possible states for a [SocketInstance]. + */ +public enum class SocketInstanceState { + /** + * There is no connection. + */ + DISCONNECTED, + + /** + * A connection is currently in progress. + */ + CONNECTING, + + /** + * Successfully connected to the server. + */ + CONNECTED, + + /** + * An error occurred and the connection is stopped. A new connection attempt may be made using the reconnect function. + */ + ERROR, + + /** + * The instance is stopped and cannot be started again. + */ + STOPPED, +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/Serializers.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/Serializers.kt new file mode 100644 index 000000000..f290c73a2 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/Serializers.kt @@ -0,0 +1,85 @@ +package org.jellyfin.sdk.api.sockets.data + +import kotlinx.serialization.serializer +import org.jellyfin.sdk.model.api.SessionMessageType +import org.jellyfin.sdk.model.socket.ActivityLogEntryMessage +import org.jellyfin.sdk.model.socket.ActivityLogEntryStartMessage +import org.jellyfin.sdk.model.socket.ActivityLogEntryStopMessage +import org.jellyfin.sdk.model.socket.ForceKeepAliveMessage +import org.jellyfin.sdk.model.socket.GeneralCommandMessage +import org.jellyfin.sdk.model.socket.KeepAliveMessage +import org.jellyfin.sdk.model.socket.LibraryChangedMessage +import org.jellyfin.sdk.model.socket.PackageInstallationCancelledMessage +import org.jellyfin.sdk.model.socket.PackageInstallationCompletedMessage +import org.jellyfin.sdk.model.socket.PackageInstallationFailedMessage +import org.jellyfin.sdk.model.socket.PackageInstallingMessage +import org.jellyfin.sdk.model.socket.PackageUninstalledMessage +import org.jellyfin.sdk.model.socket.PlayMessage +import org.jellyfin.sdk.model.socket.PlayStateMessage +import org.jellyfin.sdk.model.socket.RefreshProgressMessage +import org.jellyfin.sdk.model.socket.RestartRequiredMessage +import org.jellyfin.sdk.model.socket.ScheduledTaskEndedMessage +import org.jellyfin.sdk.model.socket.ScheduledTasksInfoMessage +import org.jellyfin.sdk.model.socket.ScheduledTasksInfoStartMessage +import org.jellyfin.sdk.model.socket.ScheduledTasksInfoStopMessage +import org.jellyfin.sdk.model.socket.SeriesTimerCancelledMessage +import org.jellyfin.sdk.model.socket.SeriesTimerCreatedMessage +import org.jellyfin.sdk.model.socket.ServerRestartingMessage +import org.jellyfin.sdk.model.socket.ServerShuttingDownMessage +import org.jellyfin.sdk.model.socket.SessionsMessage +import org.jellyfin.sdk.model.socket.SessionsStartMessage +import org.jellyfin.sdk.model.socket.SessionsStopMessage +import org.jellyfin.sdk.model.socket.SyncPlayCommandMessage +import org.jellyfin.sdk.model.socket.SyncPlayGroupUpdateMessage +import org.jellyfin.sdk.model.socket.TimerCancelledMessage +import org.jellyfin.sdk.model.socket.TimerCreatedMessage +import org.jellyfin.sdk.model.socket.UserDataChangedMessage +import org.jellyfin.sdk.model.socket.UserDeletedMessage +import org.jellyfin.sdk.model.socket.UserUpdatedMessage + +/** + * Mapping between [SessionMessageType] enum and their respective serializers. + */ +internal val SessionMessageType.serializer + get() = when (this) { + // Receive only - should not be possible to send + SessionMessageType.FORCE_KEEP_ALIVE -> serializer() + SessionMessageType.GENERAL_COMMAND -> serializer() + SessionMessageType.USER_DATA_CHANGED -> serializer() + SessionMessageType.SESSIONS -> serializer() + SessionMessageType.PLAY -> serializer() + SessionMessageType.SYNC_PLAY_COMMAND -> serializer() + SessionMessageType.SYNC_PLAY_GROUP_UPDATE -> serializer() + SessionMessageType.PLAYSTATE -> serializer() + SessionMessageType.RESTART_REQUIRED -> serializer() + SessionMessageType.SERVER_SHUTTING_DOWN -> serializer() + SessionMessageType.SERVER_RESTARTING -> serializer() + SessionMessageType.LIBRARY_CHANGED -> serializer() + SessionMessageType.USER_DELETED -> serializer() + SessionMessageType.USER_UPDATED -> serializer() + SessionMessageType.SERIES_TIMER_CREATED -> serializer() + SessionMessageType.TIMER_CREATED -> serializer() + SessionMessageType.SERIES_TIMER_CANCELLED -> serializer() + SessionMessageType.TIMER_CANCELLED -> serializer() + SessionMessageType.REFRESH_PROGRESS -> serializer() + SessionMessageType.SCHEDULED_TASK_ENDED -> serializer() + SessionMessageType.PACKAGE_INSTALLATION_CANCELLED -> serializer() + SessionMessageType.PACKAGE_INSTALLATION_FAILED -> serializer() + SessionMessageType.PACKAGE_INSTALLATION_COMPLETED -> serializer() + SessionMessageType.PACKAGE_INSTALLING -> serializer() + SessionMessageType.PACKAGE_UNINSTALLED -> serializer() + SessionMessageType.ACTIVITY_LOG_ENTRY -> serializer() + SessionMessageType.SCHEDULED_TASKS_INFO -> serializer() + + // Shared type, only implemented as outgoing message + // see comment in ApiSerializer for more info + SessionMessageType.KEEP_ALIVE -> serializer() + + // Send only - should not be possible to receive + SessionMessageType.ACTIVITY_LOG_ENTRY_START -> serializer() + SessionMessageType.ACTIVITY_LOG_ENTRY_STOP -> serializer() + SessionMessageType.SESSIONS_START -> serializer() + SessionMessageType.SESSIONS_STOP -> serializer() + SessionMessageType.SCHEDULED_TASKS_INFO_START -> serializer() + SessionMessageType.SCHEDULED_TASKS_INFO_STOP -> serializer() + } diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/SubscriptionType.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/SubscriptionType.kt new file mode 100644 index 000000000..9568781b5 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/SubscriptionType.kt @@ -0,0 +1,31 @@ +package org.jellyfin.sdk.api.sockets.data + +import org.jellyfin.sdk.model.socket.IncomingSocketMessage +import org.jellyfin.sdk.model.socket.OutgoingSocketMessage +import org.jellyfin.sdk.model.socket.PeriodicListenerPeriod +import kotlin.reflect.KClass + +/** + * Information about a subscription. Contains the incoming message type and the outgoing messages types used + * to start and stop the subscription. + */ +public data class SubscriptionType( + val messageType: KClass, + val createStartMessage: (period: PeriodicListenerPeriod) -> OutgoingSocketMessage, + val createStopMessage: () -> OutgoingSocketMessage, +) + +/** + * Create instance of [SubscriptionType]. + */ +internal inline fun subscriptionType( + noinline createStartMessage: (period: PeriodicListenerPeriod) -> OutgoingSocketMessage, + noinline createStopMessage: () -> OutgoingSocketMessage, +): SubscriptionType = SubscriptionType(MESSAGE::class, createStartMessage, createStopMessage) + +/** + * Find the subscription type for a given [IncomingSocketMessage]. Used for automatic subscription handling in the + * WebSocket API. + */ +public val KClass.subscriptionType: SubscriptionType? + get() = SUBSCRIPTION_TYPES.firstOrNull { it.messageType == this } diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/subscriptionTypes.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/subscriptionTypes.kt new file mode 100644 index 000000000..687e3ce3c --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/data/subscriptionTypes.kt @@ -0,0 +1,24 @@ +package org.jellyfin.sdk.api.sockets.data + +import org.jellyfin.sdk.model.socket.ActivityLogEntryMessage +import org.jellyfin.sdk.model.socket.ActivityLogEntryStartMessage +import org.jellyfin.sdk.model.socket.ActivityLogEntryStopMessage +import org.jellyfin.sdk.model.socket.IncomingSocketMessage +import org.jellyfin.sdk.model.socket.ScheduledTasksInfoMessage +import org.jellyfin.sdk.model.socket.ScheduledTasksInfoStartMessage +import org.jellyfin.sdk.model.socket.ScheduledTasksInfoStopMessage +import org.jellyfin.sdk.model.socket.SessionsMessage +import org.jellyfin.sdk.model.socket.SessionsStartMessage +import org.jellyfin.sdk.model.socket.SessionsStopMessage + +/** + * All socket message types that require a subscription. Each type contains the message type and start/stop message + * constructors. If a message type does not exist in this set it does not need a start/stop message. + * + * This is an internal type. Do not use this in your application. + */ +public val SUBSCRIPTION_TYPES: Set> = setOf( + subscriptionType(::SessionsStartMessage, ::SessionsStopMessage), + subscriptionType(::ActivityLogEntryStartMessage, ::ActivityLogEntryStopMessage), + subscriptionType(::ScheduledTasksInfoStartMessage, ::ScheduledTasksInfoStopMessage), +) diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/exception/SocketException.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/exception/SocketException.kt new file mode 100644 index 000000000..4b2a6ecb9 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/exception/SocketException.kt @@ -0,0 +1,11 @@ +package org.jellyfin.sdk.api.sockets.exception + +import org.jellyfin.sdk.api.client.exception.ApiClientException + +/** + * Base exception for the SocketApi and related classes. + */ +public open class SocketException( + message: String? = null, + cause: Throwable? = null, +) : ApiClientException(message, cause) diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/exception/SocketStoppedException.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/exception/SocketStoppedException.kt new file mode 100644 index 000000000..69c4b1299 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/exception/SocketStoppedException.kt @@ -0,0 +1,3 @@ +package org.jellyfin.sdk.api.sockets.exception + +public class SocketStoppedException : SocketException("The socket instance is stopped.") diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/KeepAliveHelper.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/KeepAliveHelper.kt new file mode 100644 index 000000000..df73ac817 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/KeepAliveHelper.kt @@ -0,0 +1,34 @@ +package org.jellyfin.sdk.api.sockets.helper + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.jellyfin.sdk.api.sockets.SocketInstance +import org.jellyfin.sdk.model.socket.KeepAliveMessage +import kotlin.time.Duration + +private val logger = KotlinLogging.logger {} + +internal class KeepAliveHelper( + private val coroutineScope: CoroutineScope, +) { + private var keepAliveTicker: Job? = null + + fun reset(instance: SocketInstance, lostTimeout: Duration) { + // The server considers a socket lost after [lostTimeout] seconds + // to make sure the socket doesn't get lost we divide the value by + // 2 to get the delay between sending KeepAlive messages + val delay = lostTimeout / 2 + logger.info { "Using a KeepAlive message delay of ${delay.inWholeSeconds} seconds" } + keepAliveTicker?.cancel() + keepAliveTicker = coroutineScope.launch(Dispatchers.Unconfined) { + while (true) { + instance.publish(KeepAliveMessage()) + delay(delay) + } + } + } +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/ListenerHelper.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/ListenerHelper.kt new file mode 100644 index 000000000..2995a0c8f --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/ListenerHelper.kt @@ -0,0 +1,40 @@ +package org.jellyfin.sdk.api.sockets.helper + +import org.jellyfin.sdk.api.sockets.SocketInstance +import org.jellyfin.sdk.api.sockets.data.SubscriptionType +import org.jellyfin.sdk.api.sockets.listener.SocketListener +import org.jellyfin.sdk.api.sockets.listener.SocketListenerDefinition +import org.jellyfin.sdk.model.socket.IncomingSocketMessage + +internal class ListenerHelper { + private var _listeners = mutableListOf() + val listeners: List = _listeners + + val subscriptions: List> get() = listeners.map { it.definition.subscribesTo }.flatten() + var activeSubscriptions = mutableListOf>() + + fun reportCredentialChangedReconnect() { + _listeners.removeAll { listener -> listener.definition.stopOnCredentialsChange } + } + + fun addListenerDefinition(instance: SocketInstance, definition: SocketListenerDefinition): SocketListener { + val listener = SocketListener(definition, instance) + _listeners.add(listener) + return listener + } + + fun removeListener(listener: SocketListener) { + _listeners.remove(listener) + } + + fun reset() { + _listeners.clear() + } + + fun forwardMessage(message: IncomingSocketMessage) { + for (listener in listeners) { + val acceptsMessage = listener.definition.filterTypes.any { type -> type.isInstance(message) } + if (acceptsMessage) listener.definition.listener.onReceive(message) + } + } +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/ReconnectHelper.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/ReconnectHelper.kt new file mode 100644 index 000000000..dd9f0f84a --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/helper/ReconnectHelper.kt @@ -0,0 +1,9 @@ +package org.jellyfin.sdk.api.sockets.helper + +internal class ReconnectHelper { + fun reportConnect() {} + fun reportDisconnect(){} + fun reset() {} + + fun shouldReconnectNow() {} +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketListener.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketListener.kt new file mode 100644 index 000000000..521edab75 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketListener.kt @@ -0,0 +1,15 @@ +package org.jellyfin.sdk.api.sockets.listener + +import org.jellyfin.sdk.api.sockets.SocketInstance + +public class SocketListener( + public val definition: SocketListenerDefinition, + public val instance: SocketInstance, +) { + /** + * Stop listening to new messages. Listener cannot be started again. + */ + public fun stop() { + instance.removeListener(this) + } +} diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition.kt new file mode 100644 index 000000000..565e230da --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketListenerDefinition.kt @@ -0,0 +1,12 @@ +package org.jellyfin.sdk.api.sockets.listener + +import org.jellyfin.sdk.api.sockets.data.SubscriptionType +import org.jellyfin.sdk.model.socket.IncomingSocketMessage +import kotlin.reflect.KClass + +public data class SocketListenerDefinition( + val subscribesTo: Set>, + val filterTypes: Set>, + val stopOnCredentialsChange: Boolean, + val listener: SocketMessageReceiver, +) diff --git a/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver.kt b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver.kt new file mode 100644 index 000000000..c58032059 --- /dev/null +++ b/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/listener/SocketMessageReceiver.kt @@ -0,0 +1,7 @@ +package org.jellyfin.sdk.api.sockets.listener + +import org.jellyfin.sdk.model.socket.IncomingSocketMessage + +public fun interface SocketMessageReceiver { + public fun onReceive(message: T) +} diff --git a/jellyfin-api/src/jvmMain/kotlin/org/jellyfin/sdk/api/client/KtorClient.kt b/jellyfin-api/src/jvmMain/kotlin/org/jellyfin/sdk/api/client/KtorClient.kt index a4430b580..8a4f0164d 100644 --- a/jellyfin-api/src/jvmMain/kotlin/org/jellyfin/sdk/api/client/KtorClient.kt +++ b/jellyfin-api/src/jvmMain/kotlin/org/jellyfin/sdk/api/client/KtorClient.kt @@ -22,12 +22,15 @@ import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.exception.TimeoutException import org.jellyfin.sdk.api.client.util.ApiSerializer import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory +import org.jellyfin.sdk.api.sockets.SocketInstance import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.UUID import java.net.UnknownHostException import io.ktor.http.HttpMethod as KtorHttpMethod +@Suppress("LongParameterList") public actual open class KtorClient actual constructor( override var baseUrl: String?, override var accessToken: String?, @@ -35,6 +38,7 @@ public actual open class KtorClient actual constructor( override var clientInfo: ClientInfo, override var deviceInfo: DeviceInfo, override val httpClientOptions: HttpClientOptions, + private val socketConnectionFactory: SocketConnectionFactory, ) : ApiClient() { private val client: HttpClient = HttpClient { followRedirects = httpClientOptions.followRedirects @@ -113,6 +117,8 @@ public actual open class KtorClient actual constructor( } } + public actual override fun ws(): SocketInstance = SocketInstance(this, socketConnectionFactory) + private fun HttpMethod.asKtorHttpMethod(): KtorHttpMethod = when (this) { HttpMethod.GET -> KtorHttpMethod.Get HttpMethod.POST -> KtorHttpMethod.Post diff --git a/jellyfin-core/api/android/jellyfin-core.api b/jellyfin-core/api/android/jellyfin-core.api index 9e22a8bab..16a05cb16 100644 --- a/jellyfin-core/api/android/jellyfin-core.api +++ b/jellyfin-core/api/android/jellyfin-core.api @@ -34,18 +34,20 @@ public final class org/jellyfin/sdk/JellyfinKt { public final class org/jellyfin/sdk/JellyfinOptions { public static final field Companion Lorg/jellyfin/sdk/JellyfinOptions$Companion; - public fun (Landroid/content/Context;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;)V + public fun (Landroid/content/Context;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)V public final fun component1 ()Landroid/content/Context; public final fun component2 ()Lorg/jellyfin/sdk/model/ClientInfo; public final fun component3 ()Lorg/jellyfin/sdk/model/DeviceInfo; public final fun component4 ()Lorg/jellyfin/sdk/util/ApiClientFactory; - public final fun copy (Landroid/content/Context;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;)Lorg/jellyfin/sdk/JellyfinOptions; - public static synthetic fun copy$default (Lorg/jellyfin/sdk/JellyfinOptions;Landroid/content/Context;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;ILjava/lang/Object;)Lorg/jellyfin/sdk/JellyfinOptions; + public final fun component5 ()Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory; + public final fun copy (Landroid/content/Context;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)Lorg/jellyfin/sdk/JellyfinOptions; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/JellyfinOptions;Landroid/content/Context;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;ILjava/lang/Object;)Lorg/jellyfin/sdk/JellyfinOptions; public fun equals (Ljava/lang/Object;)Z public final fun getApiClientFactory ()Lorg/jellyfin/sdk/util/ApiClientFactory; public final fun getClientInfo ()Lorg/jellyfin/sdk/model/ClientInfo; public final fun getContext ()Landroid/content/Context; public final fun getDeviceInfo ()Lorg/jellyfin/sdk/model/DeviceInfo; + public final fun getSocketConnectionFactory ()Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -57,10 +59,12 @@ public final class org/jellyfin/sdk/JellyfinOptions$Builder { public final fun getClientInfo ()Lorg/jellyfin/sdk/model/ClientInfo; public final fun getContext ()Landroid/content/Context; public final fun getDeviceInfo ()Lorg/jellyfin/sdk/model/DeviceInfo; + public final fun getSocketConnectionFactory ()Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory; public final fun setApiClientFactory (Lorg/jellyfin/sdk/util/ApiClientFactory;)V public final fun setClientInfo (Lorg/jellyfin/sdk/model/ClientInfo;)V public final fun setContext (Landroid/content/Context;)V public final fun setDeviceInfo (Lorg/jellyfin/sdk/model/DeviceInfo;)V + public final fun setSocketConnectionFactory (Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)V } public final class org/jellyfin/sdk/JellyfinOptions$Companion { @@ -254,6 +258,6 @@ public final class org/jellyfin/sdk/discovery/RecommendedServerIssue$Unsupported } public abstract interface class org/jellyfin/sdk/util/ApiClientFactory { - public abstract fun create (Ljava/lang/String;Ljava/lang/String;Ljava/util/UUID;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/api/client/HttpClientOptions;)Lorg/jellyfin/sdk/api/client/ApiClient; + public abstract fun create (Ljava/lang/String;Ljava/lang/String;Ljava/util/UUID;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/api/client/HttpClientOptions;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)Lorg/jellyfin/sdk/api/client/ApiClient; } diff --git a/jellyfin-core/api/jvm/jellyfin-core.api b/jellyfin-core/api/jvm/jellyfin-core.api index fc03536a4..8d5c59693 100644 --- a/jellyfin-core/api/jvm/jellyfin-core.api +++ b/jellyfin-core/api/jvm/jellyfin-core.api @@ -27,16 +27,18 @@ public final class org/jellyfin/sdk/JellyfinKt { public final class org/jellyfin/sdk/JellyfinOptions { public static final field Companion Lorg/jellyfin/sdk/JellyfinOptions$Companion; - public fun (Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;)V + public fun (Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)V public final fun component1 ()Lorg/jellyfin/sdk/model/ClientInfo; public final fun component2 ()Lorg/jellyfin/sdk/model/DeviceInfo; public final fun component3 ()Lorg/jellyfin/sdk/util/ApiClientFactory; - public final fun copy (Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;)Lorg/jellyfin/sdk/JellyfinOptions; - public static synthetic fun copy$default (Lorg/jellyfin/sdk/JellyfinOptions;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;ILjava/lang/Object;)Lorg/jellyfin/sdk/JellyfinOptions; + public final fun component4 ()Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory; + public final fun copy (Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)Lorg/jellyfin/sdk/JellyfinOptions; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/JellyfinOptions;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/util/ApiClientFactory;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;ILjava/lang/Object;)Lorg/jellyfin/sdk/JellyfinOptions; public fun equals (Ljava/lang/Object;)Z public final fun getApiClientFactory ()Lorg/jellyfin/sdk/util/ApiClientFactory; public final fun getClientInfo ()Lorg/jellyfin/sdk/model/ClientInfo; public final fun getDeviceInfo ()Lorg/jellyfin/sdk/model/DeviceInfo; + public final fun getSocketConnectionFactory ()Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -47,9 +49,11 @@ public final class org/jellyfin/sdk/JellyfinOptions$Builder { public final fun getApiClientFactory ()Lorg/jellyfin/sdk/util/ApiClientFactory; public final fun getClientInfo ()Lorg/jellyfin/sdk/model/ClientInfo; public final fun getDeviceInfo ()Lorg/jellyfin/sdk/model/DeviceInfo; + public final fun getSocketConnectionFactory ()Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory; public final fun setApiClientFactory (Lorg/jellyfin/sdk/util/ApiClientFactory;)V public final fun setClientInfo (Lorg/jellyfin/sdk/model/ClientInfo;)V public final fun setDeviceInfo (Lorg/jellyfin/sdk/model/DeviceInfo;)V + public final fun setSocketConnectionFactory (Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)V } public final class org/jellyfin/sdk/JellyfinOptions$Companion { @@ -239,6 +243,6 @@ public final class org/jellyfin/sdk/discovery/RecommendedServerIssue$Unsupported } public abstract interface class org/jellyfin/sdk/util/ApiClientFactory { - public abstract fun create (Ljava/lang/String;Ljava/lang/String;Ljava/util/UUID;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/api/client/HttpClientOptions;)Lorg/jellyfin/sdk/api/client/ApiClient; + public abstract fun create (Ljava/lang/String;Ljava/lang/String;Ljava/util/UUID;Lorg/jellyfin/sdk/model/ClientInfo;Lorg/jellyfin/sdk/model/DeviceInfo;Lorg/jellyfin/sdk/api/client/HttpClientOptions;Lorg/jellyfin/sdk/api/sockets/SocketConnectionFactory;)Lorg/jellyfin/sdk/api/client/ApiClient; } diff --git a/jellyfin-core/src/androidMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt b/jellyfin-core/src/androidMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt index 7980f1351..7be0d7998 100644 --- a/jellyfin-core/src/androidMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt +++ b/jellyfin-core/src/androidMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt @@ -3,6 +3,8 @@ package org.jellyfin.sdk import android.content.Context import org.jellyfin.sdk.android.androidDevice import org.jellyfin.sdk.api.client.KtorClient +import org.jellyfin.sdk.api.sockets.KtorSocketInstanceConnection +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.util.ApiClientFactory @@ -12,12 +14,14 @@ public actual data class JellyfinOptions( public actual val clientInfo: ClientInfo?, public actual val deviceInfo: DeviceInfo?, public actual val apiClientFactory: ApiClientFactory, + public actual val socketConnectionFactory: SocketConnectionFactory, ) { public actual class Builder { public var context: Context? = null public var clientInfo: ClientInfo? = null public var deviceInfo: DeviceInfo? = null public var apiClientFactory: ApiClientFactory = ApiClientFactory(::KtorClient) + public var socketConnectionFactory: SocketConnectionFactory = SocketConnectionFactory(::KtorSocketInstanceConnection) public actual fun build(): JellyfinOptions = JellyfinOptions( context = requireNotNull(context) { @@ -26,6 +30,7 @@ public actual data class JellyfinOptions( clientInfo = clientInfo, deviceInfo = deviceInfo ?: androidDevice(context!!), apiClientFactory = apiClientFactory, + socketConnectionFactory = socketConnectionFactory, ) } diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/Jellyfin.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/Jellyfin.kt index b7629950e..5fc6a77be 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/Jellyfin.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/Jellyfin.kt @@ -63,6 +63,7 @@ public class Jellyfin( clientInfo = clientInfo, deviceInfo = deviceInfo, httpClientOptions = httpClientOptions, + socketConnectionFactory = options.socketConnectionFactory, ) } diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt index ba34d313d..c1b0a2e3d 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt @@ -1,5 +1,6 @@ package org.jellyfin.sdk +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.util.ApiClientFactory @@ -8,6 +9,7 @@ public expect class JellyfinOptions { public val clientInfo: ClientInfo? public val deviceInfo: DeviceInfo? public val apiClientFactory: ApiClientFactory + public val socketConnectionFactory: SocketConnectionFactory @Suppress("EmptyDefaultConstructor") public class Builder() { diff --git a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/util/ApiClientFactory.kt b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/util/ApiClientFactory.kt index 69a71766d..928c738ff 100644 --- a/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/util/ApiClientFactory.kt +++ b/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/util/ApiClientFactory.kt @@ -2,6 +2,7 @@ package org.jellyfin.sdk.util import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.UUID @@ -15,5 +16,6 @@ public fun interface ApiClientFactory { clientInfo: ClientInfo, deviceInfo: DeviceInfo, httpClientOptions: HttpClientOptions, + socketConnectionFactory: SocketConnectionFactory, ): ApiClient } diff --git a/jellyfin-core/src/jvmMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt b/jellyfin-core/src/jvmMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt index b76cc31fe..7ab6c8575 100644 --- a/jellyfin-core/src/jvmMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt +++ b/jellyfin-core/src/jvmMain/kotlin/org/jellyfin/sdk/JellyfinOptions.kt @@ -1,6 +1,8 @@ package org.jellyfin.sdk import org.jellyfin.sdk.api.client.KtorClient +import org.jellyfin.sdk.api.sockets.KtorSocketInstanceConnection +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.util.ApiClientFactory @@ -9,16 +11,19 @@ public actual data class JellyfinOptions( public actual val clientInfo: ClientInfo?, public actual val deviceInfo: DeviceInfo?, public actual val apiClientFactory: ApiClientFactory, + public actual val socketConnectionFactory: SocketConnectionFactory, ) { public actual class Builder { public var clientInfo: ClientInfo? = null public var deviceInfo: DeviceInfo? = null public var apiClientFactory: ApiClientFactory = ApiClientFactory(::KtorClient) + public var socketConnectionFactory: SocketConnectionFactory = SocketConnectionFactory(::KtorSocketInstanceConnection) public actual fun build(): JellyfinOptions = JellyfinOptions( clientInfo = clientInfo, deviceInfo = deviceInfo, apiClientFactory = apiClientFactory, + socketConnectionFactory = socketConnectionFactory, ) } diff --git a/jellyfin-model/api/jellyfin-model.api b/jellyfin-model/api/jellyfin-model.api index acc4dbec3..ead890633 100644 --- a/jellyfin-model/api/jellyfin-model.api +++ b/jellyfin-model/api/jellyfin-model.api @@ -11758,6 +11758,41 @@ public final class org/jellyfin/sdk/model/socket/PlayStateMessage$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class org/jellyfin/sdk/model/socket/RawIncomingSocketMessage { + public static final field Companion Lorg/jellyfin/sdk/model/socket/RawIncomingSocketMessage$Companion; + public synthetic fun (ILjava/util/UUID;Lorg/jellyfin/sdk/model/api/SessionMessageType;Lkotlinx/serialization/json/JsonElement;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V + public fun (Ljava/util/UUID;Lorg/jellyfin/sdk/model/api/SessionMessageType;Lkotlinx/serialization/json/JsonElement;)V + public synthetic fun (Ljava/util/UUID;Lorg/jellyfin/sdk/model/api/SessionMessageType;Lkotlinx/serialization/json/JsonElement;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/UUID; + public final fun component2 ()Lorg/jellyfin/sdk/model/api/SessionMessageType; + public final fun component3 ()Lkotlinx/serialization/json/JsonElement; + public final fun copy (Ljava/util/UUID;Lorg/jellyfin/sdk/model/api/SessionMessageType;Lkotlinx/serialization/json/JsonElement;)Lorg/jellyfin/sdk/model/socket/RawIncomingSocketMessage; + public static synthetic fun copy$default (Lorg/jellyfin/sdk/model/socket/RawIncomingSocketMessage;Ljava/util/UUID;Lorg/jellyfin/sdk/model/api/SessionMessageType;Lkotlinx/serialization/json/JsonElement;ILjava/lang/Object;)Lorg/jellyfin/sdk/model/socket/RawIncomingSocketMessage; + public fun equals (Ljava/lang/Object;)Z + public final fun getData ()Lkotlinx/serialization/json/JsonElement; + public final fun getId ()Ljava/util/UUID; + public final fun getType ()Lorg/jellyfin/sdk/model/api/SessionMessageType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public static final fun write$Self (Lorg/jellyfin/sdk/model/socket/RawIncomingSocketMessage;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +} + +public final class org/jellyfin/sdk/model/socket/RawIncomingSocketMessage$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lorg/jellyfin/sdk/model/socket/RawIncomingSocketMessage$$serializer; + public static final synthetic field descriptor Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lorg/jellyfin/sdk/model/socket/RawIncomingSocketMessage; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lorg/jellyfin/sdk/model/socket/RawIncomingSocketMessage;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class org/jellyfin/sdk/model/socket/RawIncomingSocketMessage$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class org/jellyfin/sdk/model/socket/RefreshProgressMessage : org/jellyfin/sdk/model/socket/IncomingSocketMessage { public static final field Companion Lorg/jellyfin/sdk/model/socket/RefreshProgressMessage$Companion; public synthetic fun (ILjava/util/UUID;Ljava/util/Map;Lkotlinx/serialization/internal/SerializationConstructorMarker;)V diff --git a/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/OutgoingSocketMessage.kt b/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/OutgoingSocketMessage.kt index e5fd0a085..71c021b22 100644 --- a/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/OutgoingSocketMessage.kt +++ b/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/OutgoingSocketMessage.kt @@ -1,4 +1,3 @@ package org.jellyfin.sdk.model.socket public sealed interface OutgoingSocketMessage : SocketMessage - diff --git a/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/PeriodicListenerPeriod.kt b/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/PeriodicListenerPeriod.kt index 73fea8478..327ed637e 100644 --- a/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/PeriodicListenerPeriod.kt +++ b/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/PeriodicListenerPeriod.kt @@ -13,12 +13,10 @@ public data class PeriodicListenerPeriod( val initialDelay: Long = 0, val interval: Long = 1000, ) { - override fun toString(): String { - return "$initialDelay,$interval" - } + override fun toString(): String = "$initialDelay,$interval" public companion object { - public fun fromString(str: String): PeriodicListenerPeriod? { + public fun fromString(str: String): PeriodicListenerPeriod? { val values = str.split(',') val dueTimeMs = values.getOrNull(0)?.toLongOrNull() ?: return null val periodMs = values.getOrNull(1)?.toLongOrNull() ?: return null @@ -31,7 +29,8 @@ public data class PeriodicListenerPeriod( } public class Serializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PeriodicListenerPeriod", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("PeriodicListenerPeriod", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: PeriodicListenerPeriod): Unit = encoder.encodeString(value.toString()) diff --git a/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/RawIncomingSocketMessage.kt b/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/RawIncomingSocketMessage.kt new file mode 100644 index 000000000..87442abf0 --- /dev/null +++ b/jellyfin-model/src/commonMain/kotlin/org/jellyfin/sdk/model/socket/RawIncomingSocketMessage.kt @@ -0,0 +1,21 @@ +@file:UseSerializers(UUIDSerializer::class) + +package org.jellyfin.sdk.model.socket + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.JsonElement +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.SessionMessageType +import org.jellyfin.sdk.model.serializer.UUIDSerializer + +@Serializable +public data class RawIncomingSocketMessage( + @SerialName("MessageId") + val id: UUID, + @SerialName("MessageType") + val type: SessionMessageType, + @SerialName("Data") + val data: JsonElement? = null, +) diff --git a/samples/kotlin-cli/src/main/kotlin/org/jellyfin/sample/cli/command/Observe.kt b/samples/kotlin-cli/src/main/kotlin/org/jellyfin/sample/cli/command/Observe.kt index 6f6d74753..ecace7f99 100644 --- a/samples/kotlin-cli/src/main/kotlin/org/jellyfin/sample/cli/command/Observe.kt +++ b/samples/kotlin-cli/src/main/kotlin/org/jellyfin/sample/cli/command/Observe.kt @@ -1,32 +1,35 @@ package org.jellyfin.sample.cli.command import com.github.ajalt.clikt.core.CliktCommand +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.jellyfin.sample.cli.apiInstanceHolder import org.jellyfin.sdk.Jellyfin -import org.jellyfin.sdk.api.client.extensions.webSocket -import org.jellyfin.sdk.model.socket.ActivityLogEntryStartMessage -import org.jellyfin.sdk.model.socket.ScheduledTasksInfoStartMessage -import org.jellyfin.sdk.model.socket.SessionsStartMessage +import org.jellyfin.sdk.api.sockets.addGlobalListener class Observe( - jellyfin: Jellyfin + jellyfin: Jellyfin, ) : CliktCommand("Create a WebSocket connection and listen to all events") { private val api by apiInstanceHolder(jellyfin) override fun run() = runBlocking { - val webSocketApi = api.webSocket - println("Starting subscription") - // Send start messages to receive all events - webSocketApi.publish(ActivityLogEntryStartMessage()) - webSocketApi.publish(SessionsStartMessage()) - webSocketApi.publish(ScheduledTasksInfoStartMessage()) + val connection = api.ws() + + launch { + connection.state.collect { state -> + println("State $state") + } + } // Listen for messages - webSocketApi.subscribe().collect { message -> - println(message) + // this automatically subscribes to activity log entries etc. + connection.addGlobalListener { message -> + println("Received $message") } + + awaitCancellation() } }