diff --git a/Sources/Luminate/ServerSetupView.swift b/Sources/Luminate/ServerSetupView.swift index 1a2c189..319cae3 100644 --- a/Sources/Luminate/ServerSetupView.swift +++ b/Sources/Luminate/ServerSetupView.swift @@ -1,5 +1,5 @@ -import Foundation import Adwaita +import Foundation import LuminateCore public struct ServerSetupView: View { @@ -22,10 +22,12 @@ public struct ServerSetupView: View { icon: .custom(name: "dev.bscubed.Luminate"), description: "Enter your Jellyfin server details" ) { - VStack { - EntryRow("Server URL", text: $serverURL) - EntryRow("Username", text: $username) - PasswordEntryRow("Password", text: $password) + VStack(spacing: 16) { + PreferencesGroup("Server configuration") { + EntryRow("Server URL", text: $serverURL) + EntryRow("Username", text: $username) + PasswordEntryRow("Password", text: $password) + } Button("Connect") { connect() } @@ -44,7 +46,12 @@ public struct ServerSetupView: View { } private func connect() { - guard let url = URL(string: serverURL), !username.isEmpty else { + var urlString = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") { + urlString = "https://" + urlString + } + urlString = urlString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: urlString), !username.isEmpty else { error = "Please enter a valid server URL and username" return } @@ -55,15 +62,16 @@ public struct ServerSetupView: View { let client = JellyfinClient(serverURL: url) let result = try await client.authenticate(username: username, password: password) let userId = result.User?.value1.Id ?? "" - await MainActor.run { - isLoading = false - onLogin(client, userId) - } + + isLoading = false + onLogin(client, userId) + } catch (JellyfinError.httpError(let code)) { + isLoading = false + self.error = "Failed to login. HTTP code \(code)" } catch { - await MainActor.run { - isLoading = false - self.error = error.localizedDescription - } + isLoading = false + self.error = error.localizedDescription + print(error.localizedDescription) } } } diff --git a/Sources/LuminateCore/JellyfinClient.swift b/Sources/LuminateCore/JellyfinClient.swift index 6aaf3a3..83918b6 100644 --- a/Sources/LuminateCore/JellyfinClient.swift +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -3,8 +3,47 @@ import OpenAPIRuntime import OpenAPIURLSession import HTTPTypes +struct JellyfinDateTranscoder: DateTranscoder { + func encode(_ date: Date) throws -> String { + ISO8601DateFormatter().string(from: date) + } + + func decode(_ dateString: String) throws -> Date { + let withoutExtraDigits = dateString.replacingOccurrences( + of: #"\.(\d{3})\d+"#, + with: ".$1", + options: .regularExpression + ) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: withoutExtraDigits) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: withoutExtraDigits) { + return date + } + throw DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted: \(dateString)") + ) + } +} + +private let clientName = "Luminate" +private let deviceName = "Desktop" +private let deviceId = "luminate-001" +private let clientVersion = "1.0.0" + +func mediaBrowserHeader(token: String? = nil) -> String { + if let token { + "MediaBrowser Token=\"\(token)\", Client=\"\(clientName)\", Device=\"\(deviceName)\", DeviceId=\"\(deviceId)\", Version=\"\(clientVersion)\"" + } else { + "MediaBrowser Client=\"\(clientName)\", Device=\"\(deviceName)\", DeviceId=\"\(deviceId)\", Version=\"\(clientVersion)\"" + } +} + struct AuthMiddleware: ClientMiddleware { - let token: String + let token: String? func intercept( _ request: HTTPRequest, @@ -14,7 +53,7 @@ struct AuthMiddleware: ClientMiddleware { next: (HTTPRequest, OpenAPIRuntime.HTTPBody?, URL) async throws -> (HTTPResponse, OpenAPIRuntime.HTTPBody?) ) async throws -> (HTTPResponse, OpenAPIRuntime.HTTPBody?) { var request = request - request.headerFields[.authorization] = "MediaBrowser Token=\"\(token)\"" + request.headerFields[.authorization] = mediaBrowserHeader(token: token) return try await next(request, body, baseURL) } } @@ -32,18 +71,24 @@ public actor JellyfinClient { private var token: String? private var client: Client + private let clientConfig: Configuration + public init(serverURL: URL) { self.serverURL = serverURL self.token = nil + self.clientConfig = Configuration(dateTranscoder: JellyfinDateTranscoder()) self.client = Client( serverURL: serverURL, - transport: URLSessionTransport() + configuration: clientConfig, + transport: URLSessionTransport(), + middlewares: [AuthMiddleware(token: nil)] ) } private func makeClient(token: String) -> Client { Client( serverURL: serverURL, + configuration: clientConfig, transport: URLSessionTransport(), middlewares: [AuthMiddleware(token: token)] )