From 20096beb4f71e57b306fe7cd3eff9f99d8b2f0a5 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Fri, 5 Jun 2026 01:17:28 -0400 Subject: [PATCH] Add WebSocketClient and model extensions --- .../LuminateCore/BaseItemDto+Display.swift | 31 +++++++ Sources/LuminateCore/WebSocketClient.swift | 93 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 Sources/LuminateCore/BaseItemDto+Display.swift create mode 100644 Sources/LuminateCore/WebSocketClient.swift diff --git a/Sources/LuminateCore/BaseItemDto+Display.swift b/Sources/LuminateCore/BaseItemDto+Display.swift new file mode 100644 index 0000000..80dd828 --- /dev/null +++ b/Sources/LuminateCore/BaseItemDto+Display.swift @@ -0,0 +1,31 @@ +import Foundation + +public extension Components.Schemas.BaseItemDto { + var isMovie: Bool { _Type?.value1 == .Movie } + var isSeries: Bool { _Type?.value1 == .Series } + var isEpisode: Bool { _Type?.value1 == .Episode } + var isSeason: Bool { _Type?.value1 == .Season } + var isFolder: Bool { IsFolder ?? false } + + var runtimeString: String { + guard let ticks = RunTimeTicks else { return "" } + let totalSeconds = Int(ticks / 10_000_000) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + if hours > 0 { return "\(hours)h \(minutes)m" } + return "\(minutes)m" + } + + var yearString: String { + guard let year = ProductionYear else { return "" } + return "\(year)" + } + + var primaryImageTag: String? { + ImageTags?.additionalProperties["Primary"] + } + + var backdropImageTag: String? { + BackdropImageTags?.first + } +} diff --git a/Sources/LuminateCore/WebSocketClient.swift b/Sources/LuminateCore/WebSocketClient.swift new file mode 100644 index 0000000..ec34c89 --- /dev/null +++ b/Sources/LuminateCore/WebSocketClient.swift @@ -0,0 +1,93 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public actor WebSocketClient { + private var task: URLSessionWebSocketTask? + private let session: URLSession + private let serverURL: URL + private let token: String + private var isConnected = false + + public init(serverURL: URL, token: String) { + self.serverURL = serverURL + self.token = token + self.session = URLSession(configuration: .default) + } + + public func connect() { + var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! + components.scheme = serverURL.scheme == "https" ? "wss" : "ws" + components.path = "/socket" + components.queryItems = [.init(name: "api_key", value: token)] + guard let url = components.url else { return } + task = session.webSocketTask(with: url) + task?.resume() + isConnected = true + receiveMessage() + } + + public func disconnect() { + task?.cancel(with: .goingAway, reason: nil) + isConnected = false + } + + private func receiveMessage() { + task?.receive { [weak self] result in + Task { [weak self] in + switch result { + case .success(let message): + await self?.handleMessage(message) + await self?.receiveMessage() + case .failure: + await self?.reconnect() + } + } + } + } + + private func handleMessage(_ message: URLSessionWebSocketTask.Message) { + switch message { + case .string(let text): + guard let data = text.data(using: .utf8), + let event = try? JSONDecoder().decode(WebSocketEvent.self, from: data) else { return } + Task { @MainActor in + NotificationCenter.default.post( + name: .init(event.messageType), + object: event + ) + } + case .data: + break + @unknown default: + break + } + } + + private func reconnect() async { + guard isConnected else { return } + try? await Task.sleep(nanoseconds: 5_000_000_000) + connect() + } +} + +public struct WebSocketEvent: Decodable { + public let messageType: String + public let data: [String: AnyDecodable]? +} + +public struct AnyDecodable: Decodable { + public var value: Any + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intVal = try? container.decode(Int.self) { value = intVal } + else if let doubleVal = try? container.decode(Double.self) { value = doubleVal } + else if let boolVal = try? container.decode(Bool.self) { value = boolVal } + else if let stringVal = try? container.decode(String.self) { value = stringVal } + else if let arrayVal = try? container.decode([AnyDecodable].self) { value = arrayVal.map(\.value) } + else if let dictVal = try? container.decode([String: AnyDecodable].self) { value = dictVal.mapValues(\.value) } + else { throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "AnyDecodable error")) } + } +}