diff --git a/Package.resolved b/Package.resolved index c0930cb7..43db6207 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "2.1.6" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } + }, { "identity" : "urlqueryencoder", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index f64da11f..9f7de50b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,11 +6,11 @@ import PackageDescription let package = Package( name: "JellyfinAPI", platforms: [ - .iOS(.v13), + .iOS(.v16), .macCatalyst(.v13), - .macOS(.v10_15), - .watchOS(.v6), - .tvOS(.v13), + .macOS(.v13), + .watchOS(.v9), + .tvOS(.v16), ], products: [ .library(name: "JellyfinAPI", targets: ["JellyfinAPI"]), @@ -18,6 +18,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/kean/Get", from: "2.1.6"), .package(url: "https://github.com/CreateAPI/URLQueryEncoder", from: "0.2.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), ], targets: [ .target( @@ -25,6 +26,7 @@ let package = Package( dependencies: [ .product(name: "Get", package: "Get"), .product(name: "URLQueryEncoder", package: "URLQueryEncoder"), + .product(name: "Logging", package: "swift-log"), ], path: "Sources", exclude: [ @@ -54,3 +56,4 @@ let package = Package( ), ] ) + diff --git a/README.md b/README.md index 453575f5..51b182df 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,66 @@ 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, + supportsMediaControl: true, + supportedCommands: [.displayMessage, .play, .pause] +) + +/// 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. @@ -59,4 +119,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. \ No newline at end of file +Alternatively, you can generate your own Swift Jellyfin SDK using [CreateAPI](https://github.com/CreateAPI/CreateAPI) or any other OpenAPI generator. diff --git a/Sources/Extensions/SocketError.swift b/Sources/Extensions/SocketError.swift new file mode 100644 index 00000000..11f9ddbd --- /dev/null +++ b/Sources/Extensions/SocketError.swift @@ -0,0 +1,105 @@ +// +// 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 connectionTimeout + case explicitDisconnect + case invalidURL + case maxReconnectAttemptsReached + case missingAccessTokenOrConfig + case notConnected + case decodingError(String) + case encodingFailed(String) + case serverMessageError(String) + case underlyingError(String) + + // MARK: - Error Description + + public var errorDescription: String? { + switch self { + case .connectionTimeout: + return "Connection timed out" + case .explicitDisconnect: + return "Disconnected by client" + case .invalidURL: + return "Invalid WebSocket URL" + case .maxReconnectAttemptsReached: + return "Maximum reconnection attempts reached" + case .missingAccessTokenOrConfig: + return "Missing authentication configuration" + case .notConnected: + return "WebSocket is not connected" + case .decodingError: + return "Failed to decode server message" + case .encodingFailed: + return "Failed to encode message" + case .serverMessageError: + return "Server returned an error" + case .underlyingError: + return "An underlying error occurred" + } + } + + // MARK: - Failure Reason + + public var failureReason: String? { + switch self { + case .connectionTimeout: + return "The server did not respond within the expected time frame." + case .explicitDisconnect: + return "The disconnect method was called by the client." + case .invalidURL: + return "The server URL could not be converted to a valid WebSocket URL." + case .maxReconnectAttemptsReached: + return "The socket attempted to reconnect multiple times but failed each time." + case .missingAccessTokenOrConfig: + return "The access token, device ID, or server URL required for the WebSocket connection is missing." + case .notConnected: + return "The WebSocket connection has not been established or was closed." + case .decodingError(let message): + return message + case .encodingFailed(let reason): + return reason + case .serverMessageError(let message): + return message + case .underlyingError(let message): + return message + } + } + + // MARK: - Recovery Suggestion + + public var recoverySuggestion: String? { + switch self { + case .connectionTimeout: + return "Check network connectivity and server availability." + case .explicitDisconnect: + return nil + case .invalidURL: + return "Verify the server URL in the JellyfinClient configuration." + case .maxReconnectAttemptsReached: + return "Check network connectivity and server availability, then call connect() to try again." + case .missingAccessTokenOrConfig: + return "Ensure the user is signed in and the JellyfinClient is properly configured." + case .notConnected: + return "Call connect() to establish a WebSocket connection." + case .decodingError: + return "The server may have sent an unexpected message format. Check for SDK updates." + case .encodingFailed: + return "Check that the message data is valid and can be serialized to JSON." + case .serverMessageError: + return "Review the server logs for more details." + case .underlyingError: + return "Review the error details and check network connectivity." + } + } +} diff --git a/Sources/Extensions/SocketState.swift b/Sources/Extensions/SocketState.swift new file mode 100644 index 00000000..284accff --- /dev/null +++ b/Sources/Extensions/SocketState.swift @@ -0,0 +1,46 @@ +// +// 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 connecting + case disconnecting + case idle + case closed(error: Error?) + case connected(url: URL) + case error(Error) + + // MARK: - Convenience isConnected + + var isConnected: Bool { + if case .connected = self { + return true + } + return false + } + + // MARK: - Equatable + + public static func == (lhs: SocketState, rhs: SocketState) -> Bool { + switch (lhs, rhs) { + case (.connecting, .connecting), + (.disconnecting, .disconnecting), + (.idle, .idle), + (.closed, .closed), + (.error, .error): + return true + case (.connected(let a), .connected(let b)): + return a == b + default: + return false + } + } +} diff --git a/Sources/Extensions/SocketSubscription.swift b/Sources/Extensions/SocketSubscription.swift new file mode 100644 index 00000000..7ff7690a --- /dev/null +++ b/Sources/Extensions/SocketSubscription.swift @@ -0,0 +1,29 @@ +// +// 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 + +public enum SocketSubscription: Hashable { + + case activityLog(initialDelay: Duration = .seconds(5), interval: Duration = .seconds(5)) + case scheduledTasks(initialDelay: Duration = .zero, interval: Duration = .seconds(5)) + case sessions(initialDelay: Duration = .zero, interval: Duration = .seconds(2)) + + var data: String { + switch self { + case .activityLog(let delay, let interval), + .scheduledTasks(let delay, let interval), + .sessions(let delay, let interval): + return "\(toMilliseconds(delay)),\(toMilliseconds(interval))" + } + } + + private func toMilliseconds(_ duration: Duration) -> Int { + Int(duration.components.seconds * 1000 + duration.components.attoseconds / 1_000_000_000_000_000) + } +} diff --git a/Sources/JellyfinSocket.swift b/Sources/JellyfinSocket.swift new file mode 100644 index 00000000..3681a96c --- /dev/null +++ b/Sources/JellyfinSocket.swift @@ -0,0 +1,495 @@ +// +// 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 Combine +import Foundation +import Logging + +public final class JellyfinSocket: ObservableObject, @unchecked Sendable { + + // MARK: - Public Properties + + @MainActor + public let messages = PassthroughSubject() + + @Published + public private(set) var state: SocketState = .idle + + @Published + public private(set) var lastServerActivity: Date? + + @Published + public private(set) var subscriptions = Set() + + // MARK: - Configuration + + private let client: JellyfinClient + private let userID: String? + private let supportsMediaControl: Bool + private let supportedCommands: [GeneralCommandType] + private var logger: Logger + + // MARK: - Connection Settings + + private let maxReconnectAttempts = 5 + private let reconnectDelayBase: Duration = .seconds(2) + private let connectionTimeout: TimeInterval = 10 + private let serverResponseTimeout: TimeInterval = 90 + private var keepAliveInterval: TimeInterval = 20 + + // MARK: - Internal State + + private var urlSession: URLSession! + private var webSocketTask: URLSessionWebSocketTask? + private var reconnectAttempts = 0 + private var explicitlyDisconnected = false + private var messageQueue: [Data] = [] + private var cancellables = Set() + + // MARK: - Timers + + private var keepAliveTimer: Timer? + private var connectionTimeoutTimer: Timer? + private var responseTimeoutTimer: Timer? + + // MARK: - JSON Coding + + private let encoder = JSONEncoder() + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(OpenISO8601DateFormatter()) + return decoder + }() + + // MARK: - Init + + public init( + client: JellyfinClient, + userID: String? = nil, + supportsMediaControl: Bool = false, + supportedCommands: [GeneralCommandType] = GeneralCommandType.allCases, + logger: Logger = Logger(label: "JellyfinSocket") + ) { + self.client = client + self.userID = userID + self.supportsMediaControl = supportsMediaControl + self.supportedCommands = supportedCommands + self.logger = logger + + let queue = OperationQueue() + queue.name = "org.jellyfin.sdk.websocket" + queue.maxConcurrentOperationCount = 1 + + self.urlSession = URLSession( + configuration: .default, + delegate: nil, + delegateQueue: queue + ) + } + + deinit { + explicitlyDisconnected = true + webSocketTask?.cancel(with: .goingAway, reason: nil) + invalidateTimers() + + Task.detached { [client, supportsMediaControl, supportedCommands] in + await Self.updateCapabilities( + client: client, + supportsMediaControl: supportsMediaControl, + supportedCommands: supportedCommands, + enable: false + ) + } + } + + // MARK: - Connect + + public func connect() { + guard !state.isConnected, webSocketTask == nil else { + logger.warning("Already connected or connecting") + return + } + + explicitlyDisconnected = false + state = .connecting + reconnectAttempts = 0 + + startConnectionTimeout() + + Task.detached { [weak self] in + await self?.establishConnection() + } + } + + // MARK: - Disconnect + + public func disconnect() { + logger.info("Disconnecting") + explicitlyDisconnected = true + performDisconnect(error: SocketError.explicitDisconnect) + } + + // MARK: - Send + + @discardableResult + public func send(_ message: InboundWebSocketMessage) -> Bool { + guard !explicitlyDisconnected else { + logger.warning("Cannot send while disconnected") + return false + } + + do { + let data = try encoder.encode(message) + return send(data) + } catch { + logger.error("Failed to encode message: \(error)") + return false + } + } + + // MARK: - Subscribe + + public func subscribe(_ subscription: SocketSubscription) { + subscriptions.insert(subscription) + + let message: InboundWebSocketMessage = switch subscription { + case .activityLog: + .activityLogEntryStartMessage(ActivityLogEntryStartMessage(data: subscription.data, messageType: .activityLogEntryStart)) + case .scheduledTasks: + .scheduledTasksInfoStartMessage(ScheduledTasksInfoStartMessage(data: subscription.data, messageType: .scheduledTasksInfoStart)) + case .sessions: + .sessionsStartMessage(SessionsStartMessage(data: subscription.data, messageType: .sessionsStart)) + } + + send(message) + } + + // MARK: - Unsubscribe + + public func unsubscribe(_ subscription: SocketSubscription) { + subscriptions.remove(subscription) + + let message: InboundWebSocketMessage = switch subscription { + case .activityLog: + .activityLogEntryStopMessage(ActivityLogEntryStopMessage(messageType: .activityLogEntryStop)) + case .scheduledTasks: + .scheduledTasksInfoStopMessage(ScheduledTasksInfoStopMessage(messageType: .scheduledTasksInfoStop)) + case .sessions: + .sessionsStopMessage(SessionsStopMessage(messageType: .sessionsStop)) + } + + send(message) + } +} + +// MARK: - Connection + +private extension JellyfinSocket { + + func establishConnection() async { + guard !explicitlyDisconnected else { + await MainActor.run { state = .closed(error: SocketError.explicitDisconnect) } + return + } + + guard let url = buildSocketURL() else { + await MainActor.run { handleDisconnection(error: SocketError.invalidURL) } + return + } + + logger.info("Connecting to \(url.host ?? "unknown")") + + webSocketTask = urlSession.webSocketTask(with: URLRequest(url: url)) + webSocketTask?.resume() + listen() + } + + func buildSocketURL() -> URL? { + guard let token = client.accessToken else { + logger.error("Missing access token") + return nil + } + + guard var components = URLComponents(url: client.configuration.url, resolvingAgainstBaseURL: false) else { + return nil + } + + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/socket" + components.queryItems = [ + URLQueryItem(name: "api_key", value: token), + URLQueryItem(name: "deviceId", value: client.configuration.deviceID), + userID.map { URLQueryItem(name: "user_id", value: $0) } + ].compactMap { $0 } + + return components.url + } +} + +// MARK: - Listening + +private extension JellyfinSocket { + + func listen() { + webSocketTask?.receive { [weak self] result in + guard let self else { return } + + Task { @MainActor in + guard !self.explicitlyDisconnected else { return } + + switch result { + case .success(let message): + self.handleMessage(message) + self.listen() + case .failure(let error): + self.handleReceiveError(error) + } + } + } + } + + func handleMessage(_ message: URLSessionWebSocketTask.Message) { + connectionTimeoutTimer?.invalidate() + + if case .connecting = state { + onConnected() + } + + lastServerActivity = Date() + resetResponseTimeout() + + guard let data = extractData(from: message) else { return } + + do { + let decoded = try decoder.decode(OutboundWebSocketMessage.self, from: data) + handleDecodedMessage(decoded) + } catch { + logger.error("Failed to decode message: \(error)") + } + } + + func extractData(from message: URLSessionWebSocketTask.Message) -> Data? { + switch message { + case .string(let text): + return text.data(using: .utf8) + case .data(let data): + return data + @unknown default: + return nil + } + } + + func handleDecodedMessage(_ message: OutboundWebSocketMessage) { + Task { @MainActor in + messages.send(message) + } + + switch message { + case .forceKeepAliveMessage(let msg): + if let interval = msg.data { + keepAliveInterval = Double(interval) / 2.0 + startKeepAlive() + sendKeepAlive() + } + case .outboundKeepAliveMessage: + logger.debug("KeepAlive pong") + default: + break + } + } + + func handleReceiveError(_ error: Error) { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { + logger.info("WebSocket cancelled") + return + } + logger.error("Receive error: \(error)") + handleDisconnection(error: error) + } +} + +// MARK: - Connection Lifecycle + +private extension JellyfinSocket { + + func onConnected() { + logger.info("Connected") + + if let url = webSocketTask?.currentRequest?.url { + state = .connected(url: url) + } + + reconnectAttempts = 0 + + Task.detached { [client, supportsMediaControl, supportedCommands] in + await Self.updateCapabilities( + client: client, + supportsMediaControl: supportsMediaControl, + supportedCommands: supportedCommands, + enable: true + ) + } + + resubscribe() + flushMessageQueue() + startKeepAlive() + } + + func performDisconnect(error: Error?) { + invalidateTimers() + webSocketTask?.cancel(with: .goingAway, reason: nil) + webSocketTask = nil + messageQueue.removeAll() + state = .closed(error: error) + } + + func handleDisconnection(error: Error?) { + invalidateTimers() + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + + if explicitlyDisconnected { + state = .closed(error: SocketError.explicitDisconnect) + return + } + + guard reconnectAttempts < maxReconnectAttempts else { + logger.error("Max reconnect attempts reached") + state = .error(SocketError.maxReconnectAttemptsReached) + return + } + + reconnectAttempts += 1 + let delay = reconnectDelayBase * Int(pow(2.0, Double(reconnectAttempts - 1))) + logger.info("Reconnecting in \(delay) (attempt \(reconnectAttempts)/\(maxReconnectAttempts))") + + state = .connecting + + Task { + try? await Task.sleep(for: delay) + guard !explicitlyDisconnected else { return } + await establishConnection() + } + } +} + +// MARK: - Message Queue + +private extension JellyfinSocket { + + @discardableResult + func send(_ data: Data) -> Bool { + guard state.isConnected, let task = webSocketTask else { + if !explicitlyDisconnected { + messageQueue.append(data) + } + return !explicitlyDisconnected + } + + task.send(.data(data)) { [weak self] error in + if let error { + self?.logger.error("Send error: \(error)") + } + } + return true + } + + func flushMessageQueue() { + let queued = messageQueue + messageQueue.removeAll() + queued.forEach { send($0) } + } + + func resubscribe() { + subscriptions.forEach { subscribe($0) } + } +} + +// MARK: - Keep Alive + +private extension JellyfinSocket { + + func sendKeepAlive() { + logger.debug("KeepAlive ping") + send(.inboundKeepAliveMessage(InboundKeepAliveMessage(messageType: .keepAlive))) + } + + func startKeepAlive() { + stopKeepAlive() + + sendKeepAlive() + resetResponseTimeout() + + keepAliveTimer = Timer.scheduledTimer(withTimeInterval: keepAliveInterval, repeats: true) { [weak self] _ in + guard let self, self.state.isConnected, !self.explicitlyDisconnected else { + self?.stopKeepAlive() + return + } + self.sendKeepAlive() + self.resetResponseTimeout() + } + } + + func stopKeepAlive() { + keepAliveTimer?.invalidate() + keepAliveTimer = nil + responseTimeoutTimer?.invalidate() + responseTimeoutTimer = nil + } +} + +// MARK: - Timers + +private extension JellyfinSocket { + + func startConnectionTimeout() { + connectionTimeoutTimer?.invalidate() + connectionTimeoutTimer = Timer.scheduledTimer(withTimeInterval: connectionTimeout, repeats: false) { [weak self] _ in + guard let self, self.state == .connecting else { return } + self.logger.error("Connection timeout") + self.handleDisconnection(error: SocketError.connectionTimeout) + } + } + + func resetResponseTimeout() { + responseTimeoutTimer?.invalidate() + responseTimeoutTimer = Timer.scheduledTimer(withTimeInterval: serverResponseTimeout, repeats: false) { [weak self] _ in + guard let self, self.state.isConnected, !self.explicitlyDisconnected else { return } + self.logger.warning("Server response timeout") + self.handleDisconnection(error: SocketError.connectionTimeout) + } + } + + func invalidateTimers() { + keepAliveTimer?.invalidate() + keepAliveTimer = nil + connectionTimeoutTimer?.invalidate() + connectionTimeoutTimer = nil + responseTimeoutTimer?.invalidate() + responseTimeoutTimer = nil + } +} + +// MARK: - Capabilities + +private extension JellyfinSocket { + + static func updateCapabilities( + client: JellyfinClient, + supportsMediaControl: Bool, + supportedCommands: [GeneralCommandType], + enable: Bool + ) async { + var parameters = Paths.PostCapabilitiesParameters() + parameters.isSupportsMediaControl = enable ? supportsMediaControl : false + parameters.supportedCommands = enable ? supportedCommands : nil + + _ = try? await client.send(Paths.postCapabilities(parameters: parameters)) + } +}