Skip to content

Commit

Permalink
WebSocket API rewrite (#352)
Browse files Browse the repository at this point in the history
* WebSocket API rewrite
* Review changes
  • Loading branch information
nielsvanvelzen authored Mar 26, 2022
1 parent 3a191cc commit 5dd9e24
Show file tree
Hide file tree
Showing 35 changed files with 1,315 additions and 29 deletions.
180 changes: 180 additions & 0 deletions docs/websockets.md
Original file line number Diff line number Diff line change
@@ -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<UserDataChangedMessage> { 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
115 changes: 113 additions & 2 deletions jellyfin-api/api/jellyfin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <init> (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 <init> (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 <init> (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 <init> (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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 <init> (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 <init> (Lorg/jellyfin/sdk/api/sockets/WebSocketApi;Lkotlin/jvm/functions/Function2;)V
public final fun cancel ()V
Expand All @@ -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 <init> (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 <init> ()V
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
public synthetic fun <init> (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 <init> ()V
}

public final class org/jellyfin/sdk/api/sockets/listener/SocketListener {
public fun <init> (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 <init> (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
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,6 +80,8 @@ public abstract class ApiClient {
requestBody: Any? = null,
): RawResponse

public abstract fun ws(): SocketInstance

private val apiInstances = mutableMapOf<KClass<out Api>, Api>()

@Suppress("UNCHECKED_CAST")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
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,
userId: UUID? = null,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
httpClientOptions: HttpClientOptions,
socketConnectionFactory: SocketConnectionFactory,
) : ApiClient {
public override suspend fun request(
method: HttpMethod,
Expand All @@ -19,4 +23,6 @@ public expect open class KtorClient(
queryParameters: Map<String, Any?>,
requestBody: Any?,
): RawResponse

public override fun ws(): SocketInstance
}
Loading

0 comments on commit 5dd9e24

Please sign in to comment.