Skip to content

Commit

Permalink
WebSocket API rewrite
Browse files Browse the repository at this point in the history
  • Loading branch information
nielsvanvelzen committed Feb 3, 2022
1 parent c28f76f commit dfb0485
Show file tree
Hide file tree
Showing 33 changed files with 1,095 additions and 20 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 SocketApi 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 in during the lifecycle of your application. Use the `createInstance()`
function from the SocketApi to create a new instance.

```kotlin
val instance = api.socket.createInstance()
```

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 the SocketApi to listen for messages.
- The [jellyfin-androidtv] app uses the SocketApi for remote media control, reactivity and more.

[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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.jellyfin.sdk.api.client

import org.jellyfin.sdk.api.client.util.UrlBuilder
import org.jellyfin.sdk.api.operations.Api
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
Expand Down Expand Up @@ -48,6 +49,11 @@ public abstract class ApiClient {
*/
public abstract val httpClientOptions: HttpClientOptions

/**
* Factory used by the SocketApi to create connections.
*/
internal abstract val socketConnectionFactory: SocketConnectionFactory

/**
* Create a complete url based on the [baseUrl] and given parameters.
* Uses [UrlBuilder] to create the path from the [pathTemplate] and [pathParameters].
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package org.jellyfin.sdk.api.client

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

@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 Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.jellyfin.sdk.api.client.extensions

import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.sockets.SocketApi

public val ApiClient.socket: SocketApi
get() = getOrCreateApi { SocketApi(it) }
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,41 @@ 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
allowSpecialFloatingPointValues = true
useArrayPolymorphism = false
}

private val jsonSocketMessage: Json = Json(json) {
encodeDefaults = true
}

public fun encodeRequestBody(requestBody: Any? = null): String? {
if (requestBody == null) return null

Expand All @@ -28,4 +48,50 @@ 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<Any>
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<RawIncomingSocketMessage>(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())

// Flatten data object or keep it when it's not an object
val data = rawMessage.data
if (data is JsonObject) data.entries.forEach { (key, value) -> put(key, value) }
else if (data != null) put(SOCKET_MESSAGE_DATA, data)
}

return jsonSocketMessage.decodeFromJsonElement(rawMessage.type.serializer, modifiedJson) as? IncomingSocketMessage
}
}
Loading

0 comments on commit dfb0485

Please sign in to comment.