Add WebSocketClient and model extensions

This commit is contained in:
Brendan Szymanski 2026-06-05 01:17:28 -04:00
parent a58c5129fc
commit 20096beb4f
2 changed files with 124 additions and 0 deletions

View file

@ -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
}
}

View file

@ -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")) }
}
}