Skip to content

[WIP] WebSocket #46

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 57 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
a2362bb
WebSocket
JPKribs May 9, 2025
391d6e4
NIO
JPKribs May 9, 2025
d15941d
Publicize and comments
JPKribs May 9, 2025
915fdac
???
JPKribs May 9, 2025
bbb7aa4
Rework
JPKribs May 9, 2025
81ecbee
WIP
JPKribs May 9, 2025
ad8b8a9
WIP
JPKribs May 9, 2025
5d6a5c9
Rework
JPKribs May 9, 2025
b7cbcb4
WIP
JPKribs May 9, 2025
a5d2d9b
messages
JPKribs May 9, 2025
ce49f56
messages pt 2
JPKribs May 9, 2025
2f4265d
KeepAlive Messaging
JPKribs May 9, 2025
9fb6b70
Keep alive fixes
JPKribs May 9, 2025
259df1a
Socket fixes
JPKribs May 9, 2025
4049108
Remove hardtyping
JPKribs May 9, 2025
6c86898
Connections and Fixes
JPKribs May 9, 2025
93bd385
Trying to get more than just KeepAlive
JPKribs May 9, 2025
a2c0be9
From the top....
JPKribs May 9, 2025
02368a1
Subscription? I hardly know her!
JPKribs May 9, 2025
5feb5e0
Inbound vs Outbound. Whoops...
JPKribs May 9, 2025
f478b12
Decoding Errors
JPKribs May 10, 2025
b56651f
WIP
JPKribs May 10, 2025
b89b29e
Decoding hell
JPKribs May 10, 2025
dbb83d8
...
JPKribs May 10, 2025
9163f7a
WIP
JPKribs May 10, 2025
904199f
Version fix and more testing
JPKribs May 10, 2025
0f5185b
Complete rework
JPKribs May 10, 2025
4c9b170
Queuing
JPKribs May 10, 2025
eb1a659
Inbound encoding now?!
JPKribs May 10, 2025
d96901f
Heartbeat and maybe trying to figure out encoding some more.
JPKribs May 10, 2025
ed836ba
Migration to NIO to try and get more options
JPKribs May 10, 2025
842f943
import NIOConcurrencyHelpers
JPKribs May 10, 2025
b97f221
NIO Packages
JPKribs May 10, 2025
fc754cc
Nevemind
JPKribs May 10, 2025
f7cd2d4
Testing
JPKribs May 10, 2025
8de83db
Registration kind of worked out
JPKribs May 10, 2025
66a3192
Logging. Connections are good. UserData is good. Sessions are still n…
JPKribs May 10, 2025
2d617c8
Fully functional (sometimes) need to figure out how to make the conne…
JPKribs May 11, 2025
71aa821
Usable, functional, but unsubscribable
JPKribs May 11, 2025
009d6af
Strong Typing - no Subscriptions
JPKribs May 11, 2025
a5188a3
functional and messy and bad
JPKribs May 11, 2025
01779d6
Commented. Still need stronger typing. Also, issues occur when resumi…
JPKribs May 11, 2025
56e2054
Recreate the socket when needed
JPKribs May 11, 2025
78ed0e0
Rework - Much more stable. Still need to figure out subscriptions.
JPKribs May 12, 2025
a0a573a
FUNCTIONAL!!!!!! - Subscriptions are still not working but all else f…
JPKribs May 12, 2025
ffdeb8c
Message Cleanup
JPKribs May 12, 2025
aac95a4
Cleanup + Subscription fix.
JPKribs May 12, 2025
b81441e
Merge branch 'jellyfin:main' into webSocket
JPKribs May 12, 2025
4c8825b
Get rid of unnecessary changes to getBrandingCss
JPKribs May 12, 2025
c3b32f0
Swift 6 Sendable fixes - No more warnings! Also, threading updates. O…
JPKribs May 13, 2025
593b43e
Fix publisher name
JPKribs May 13, 2025
626abf9
Take the whole class off of the MainActor in favor of use the parts t…
JPKribs May 13, 2025
ef97c3b
Lower background priority. Blindly trying to improve performance.
JPKribs May 13, 2025
2ea6457
Cleanup and more sendable handling.
JPKribs May 13, 2025
caa541b
README Documentation
JPKribs May 13, 2025
769035b
Update README.md
JPKribs May 13, 2025
6b37c3f
Format README.md
JPKribs May 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,67 @@ let response = jellyfinClient.signIn(username: "jelly", password: "fin")

Alternatively, you can use your own network stack with the generated **Entities** and **Paths**.

## WebSocket

`JellyfinSocket` creates and manages a persistent WebSocket connection to the Jellyfin server, delivering real-time updates. Once connected, higher volumne endpoints can be subscribed to like sessions, scheduled tasks, or activity logs.

```swift
/// Create a WebSocket instance with all available parameters
let socket = JellyfinSocket(
client: client,
userID: user.id,
isSupportsMediaControl: true,
supportedCommands: [.displayMessage, .play, .pause],
logLevel: .debug
)

/// Observe socket state changes
let stateSubscription = socket.$state
.receive(on: DispatchQueue.main)
.sink { state in
switch state {
case .idle:
print("Socket is idle")
case .connecting:
print("Connecting...")
case .connected(let url):
print("Connected to: \(url)")
case .disconnecting:
print("Disconnecting...")
case .closed(let error):
print("Closed: \(String(describing: error))")
case .error(let error):
print("Socket error: \(error)")
}
}

/// Observe parsed server messages
let messageSubscription = socket.messages
.receive(on: DispatchQueue.main)
.sink { message in
switch message {
case .sessionsMessage(let msg):
print("Received session update: \(msg)")
case .outboundKeepAliveMessage:
print("Received keep-alive pong")
default:
break
}
}

/// Connect the socket
socket.connect()

/// Subscribe to sessions feed immediately with updates every 2 seconds
socket.subscribe(.sessions(initialDelayMs: 0, intervalMs: 2000))

/// Later, unsubscribe
socket.unsubscribe(.sessions())

/// Gracefully disconnect (optional; also triggered by deinit)
socket.disconnect()
```

## Quick Connect

The `QuickConnect` object has been provided to perform the Quick Connect authorization flow.
Expand Down Expand Up @@ -59,4 +120,4 @@ quickConnect.start()
$ make update
```

Alternatively, you can generate your own Swift Jellyfin SDK using [CreateAPI](https://github.com/CreateAPI/CreateAPI) or any other OpenAPI generator.
Alternatively, you can generate your own Swift Jellyfin SDK using [CreateAPI](https://github.com/CreateAPI/CreateAPI) or any other OpenAPI generator.
54 changes: 54 additions & 0 deletions Sources/Extensions/SocketError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// jellyfin-sdk-swift is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//

import Foundation

/// Error types specific to the JellyfinSocket
public enum SocketError: Error, LocalizedError, Equatable {
case notConnected
case missingAccessTokenOrConfig
case invalidURL
case encodingFailed(String)
case maxReconnectAttemptsReached
case explicitDisconnect
case connectionTimeout
case serverMessageError(String)
case underlyingError(String)
case decodingError(String)

/// Human-readable error descriptions
public var errorDescription: String? {
switch self {
case .notConnected:
return "WebSocket is not connected."
case .missingAccessTokenOrConfig:
return "Missing access token, device ID, or server URL for WebSocket connection."
case .invalidURL:
return "Invalid WebSocket URL."
case .encodingFailed(let reason):
return "Failed to encode message: \(reason)"
case .maxReconnectAttemptsReached:
return "Maximum reconnection attempts reached."
case .explicitDisconnect:
return "Disconnected by client."
case .connectionTimeout:
return "Connection attempt timed out or server unresponsive."
case .serverMessageError(let message):
return "Server returned an error message: \(message)"
case .underlyingError(let message):
return "An underlying error occurred: \(message)"
case .decodingError(let message):
return "Failed to decode server message: \(message)"
}
}

/// Equality comparison for SocketError types
public static func == (lhs: SocketError, rhs: SocketError) -> Bool {
lhs.localizedDescription == rhs.localizedDescription
}
}
93 changes: 93 additions & 0 deletions Sources/Extensions/SocketMessages.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// jellyfin-sdk-swift is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//

extension InboundWebSocketMessage {
/// Returns the message type for the inbound message
var sessionMessageType: SessionMessageType? {
switch self {
case .activityLogEntryStartMessage(let message):
return message.messageType
case .activityLogEntryStopMessage(let message):
return message.messageType
case .inboundKeepAliveMessage(let message):
return message.messageType
case .scheduledTasksInfoStartMessage(let message):
return message.messageType
case .scheduledTasksInfoStopMessage(let message):
return message.messageType
case .sessionsStartMessage(let message):
return message.messageType
case .sessionsStopMessage(let message):
return message.messageType
}
}
}

extension OutboundWebSocketMessage {
/// Returns the message type for the outbound message
var sessionMessageType: SessionMessageType? {
switch self {
case .activityLogEntryMessage(let message):
return message.messageType
case .forceKeepAliveMessage(let message):
return message.messageType
case .generalCommandMessage(let message):
return message.messageType
case .libraryChangedMessage(let message):
return message.messageType
case .outboundKeepAliveMessage(let message):
return message.messageType
case .playMessage(let message):
return message.messageType
case .playstateMessage(let message):
return message.messageType
case .pluginInstallationCancelledMessage(let message):
return message.messageType
case .pluginInstallationCompletedMessage(let message):
return message.messageType
case .pluginInstallationFailedMessage(let message):
return message.messageType
case .pluginInstallingMessage(let message):
return message.messageType
case .pluginUninstalledMessage(let message):
return message.messageType
case .refreshProgressMessage(let message):
return message.messageType
case .restartRequiredMessage(let message):
return message.messageType
case .scheduledTaskEndedMessage(let message):
return message.messageType
case .scheduledTasksInfoMessage(let message):
return message.messageType
case .seriesTimerCancelledMessage(let message):
return message.messageType
case .seriesTimerCreatedMessage(let message):
return message.messageType
case .serverRestartingMessage(let message):
return message.messageType
case .serverShuttingDownMessage(let message):
return message.messageType
case .sessionsMessage(let message):
return message.messageType
case .syncPlayCommandMessage(let message):
return message.messageType
case .syncPlayGroupUpdateCommandMessage(let message):
return message.messageType
case .timerCancelledMessage(let message):
return message.messageType
case .timerCreatedMessage(let message):
return message.messageType
case .userDataChangedMessage(let message):
return message.messageType
case .userDeletedMessage(let message):
return message.messageType
case .userUpdatedMessage(let message):
return message.messageType
}
}
}
67 changes: 67 additions & 0 deletions Sources/Extensions/SocketState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// jellyfin-sdk-swift is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//

import Foundation

/// Represents the possible states of the WebSocket connection
public enum SocketState: Equatable {
case idle
case connecting
case connected(url: URL)
case disconnecting
case closed(error: Error?)
case error(Error)

/// Indicates whether the socket is currently in connected state
var isConnected: Bool {
if case .connected = self { return true }
return false
}

/// Equality comparison for JellyfinSocket.State
public static func == (lhs: SocketState, rhs: SocketState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle):
return true
case (.connecting, .connecting):
return true
case let (.connected(url1), .connected(url2)):
return url1 == url2
case (.disconnecting, .disconnecting):
return true
case let (.closed(err1), .closed(err2)):
if err1 == nil && err2 == nil {
return true
}
if let e1 = err1 as? SocketError, let e2 = err2 as? SocketError {
return e1 == e2
}
return err1?.localizedDescription == err2?.localizedDescription
case let (.error(err1), .error(err2)):
if let e1 = err1 as? SocketError, let e2 = err2 as? SocketError {
return e1 == e2
}
return err1.localizedDescription == err2.localizedDescription
default:
return false
}
}

/// Checks if the state is a closed error with the specified error
/// - Parameter error: The error to compare against
/// - Returns: Whether the state is a closed error with the specified error
func isClosedError(_ error: Error) -> Bool {
if case .closed(let e) = self {
if let socketErr = e as? SocketError, let comparableErr = error as? SocketError {
return socketErr == comparableErr
}
return e?.localizedDescription == error.localizedDescription
}
return false
}
}
30 changes: 30 additions & 0 deletions Sources/Extensions/SocketSubscription.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// jellyfin-sdk-swift is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//

import Foundation

/// Types of subscriptions supported by the Jellyfin WebSocket
public enum SocketSubscription: Hashable {
case sessions(initialDelayMs: Int = 0, intervalMs: Int = 10000)
case scheduledTasks(initialDelayMs: Int = 0, intervalMs: Int = 60000)
case activityLog(initialDelayMs: Int = 0, intervalMs: Int = 5000)

/// Formats the subscription parameters as a string for the server
var data: String? {
let parameters: (Int, Int)
switch self {
case .sessions(let delay, let interval):
parameters = (delay, interval)
case .scheduledTasks(let delay, let interval):
parameters = (delay, interval)
case .activityLog(let delay, let interval):
parameters = (delay, interval)
}
return "\(parameters.0),\(parameters.1)"
}
}
Loading