import Foundation import MediaKit /// Wer angemeldet ist. public struct JellyfinAccount: Sendable, Equatable { public let userID: String public let userName: String public let token: String /// Ob dieses Konto den Server verwalten darf. /// /// Entscheidet, ob der Verwaltungsbereich überhaupt erscheint. Ihn zu /// zeigen und bei jedem Tipp „keine Berechtigung" zu melden, wäre die /// schlechtere Antwort. public let isAdministrator: Bool public init(userID: String, userName: String, token: String, isAdministrator: Bool) { self.userID = userID self.userName = userName self.token = token self.isAdministrator = isAdministrator } } /// Was ein Server über sich verrät, bevor man angemeldet ist. public struct JellyfinServerInfo: Sendable, Equatable, Decodable { public let serverName: String public let version: String? public let id: String? /// Ob die Einrichtung abgeschlossen ist. public let startupWizardCompleted: Bool? private enum CodingKeys: String, CodingKey { case serverName = "ServerName" case version = "Version" case id = "Id" case startupWizardCompleted = "StartupWizardCompleted" } } /// Der Code, mit dem sich die App ohne Passwort anmeldet. public struct QuickConnectRequest: Sendable, Equatable, Decodable { public let secret: String public let code: String private enum CodingKeys: String, CodingKey { case secret = "Secret" case code = "Code" } } /// Meldet an einem Jellyfin-Server an. /// /// Zwei Wege, und die Reihenfolge ist Absicht. /// /// **Quick Connect** ist der erste: die App zeigt einen sechsstelligen Code, /// den man in einem bereits angemeldeten Client bestätigt. Das Passwort /// erreicht Flixy dabei nie — dieselbe Zusage, die bei Plex gilt, wo die /// Anmeldemaske von Plex selbst kommt. /// /// **Benutzername und Passwort** sind der Rückfall, falls der Server Quick /// Connect abgeschaltet hat. Dann hält die App das Passwort für die Dauer /// einer Anfrage — sie speichert es nicht, protokolliert es nicht und legt es /// nirgends ab. Gespeichert wird nur der Token, und der im Schlüsselbund. public actor JellyfinAuthenticator { private let client: JellyfinClient public init(client: JellyfinClient) { self.client = client } /// Fragt den Server, ob er überhaupt einer ist. public func serverInfo() async throws(MediaBackendError) -> JellyfinServerInfo { try await client.send( JellyfinRequest(path: "/System/Info/Public", timeout: .seconds(10)), as: JellyfinServerInfo.self ) } /// Ob der Server die Anmeldung per Code anbietet. public func supportsQuickConnect() async -> Bool { // Schweigt er oder antwortet mit einem Fehler, gilt es als // abgeschaltet. Ein Code, den niemand bestätigen kann, wäre eine // Sackgasse mit Wartezeit. guard let data = try? await client.send(JellyfinRequest(path: "/QuickConnect/Enabled")) else { return false } let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) return text.lowercased() == "true" } public func beginQuickConnect() async throws(MediaBackendError) -> QuickConnectRequest { try await client.send( JellyfinRequest(method: .post, path: "/QuickConnect/Initiate"), as: QuickConnectRequest.self ) } /// Sieht nach, ob der Code inzwischen bestätigt wurde. /// /// Gibt `nil` zurück, solange er es nicht ist — der Aufrufer führt seine /// eigene Schleife, weil er nebenher auf Abbruch achten muss. public func completeQuickConnect( secret: String ) async throws(MediaBackendError) -> JellyfinAccount? { let state = try await client.send( JellyfinRequest( path: "/QuickConnect/Connect", query: [URLQueryItem(name: "secret", value: secret)] ), as: QuickConnectState.self ) guard state.authenticated else { return nil } let body = try encode(["Secret": secret]) return try await authenticate( JellyfinRequest(method: .post, path: "/Users/AuthenticateWithQuickConnect", body: body) ) } /// Der Rückfall, wenn Quick Connect abgeschaltet ist. public func signIn( userName: String, password: String ) async throws(MediaBackendError) -> JellyfinAccount { let body = try encode(["Username": userName, "Pw": password]) return try await authenticate( JellyfinRequest(method: .post, path: "/Users/AuthenticateByName", body: body) ) } private func authenticate( _ request: JellyfinRequest ) async throws(MediaBackendError) -> JellyfinAccount { let result = try await client.send(request, as: AuthenticationResult.self) await client.setToken(result.accessToken) return JellyfinAccount( userID: result.user.id, userName: result.user.name, token: result.accessToken, isAdministrator: result.user.policy?.isAdministrator ?? false ) } private func encode(_ fields: [String: String]) throws(MediaBackendError) -> Data { do { return try JSONSerialization.data(withJSONObject: fields) } catch { throw .decoding(description: String(describing: error)) } } } private struct QuickConnectState: Decodable, Sendable { let authenticated: Bool private enum CodingKeys: String, CodingKey { case authenticated = "Authenticated" } } private struct AuthenticationResult: Decodable, Sendable { let accessToken: String let user: User struct User: Decodable, Sendable { let id: String let name: String let policy: Policy? private enum CodingKeys: String, CodingKey { case id = "Id" case name = "Name" case policy = "Policy" } } struct Policy: Decodable, Sendable { let isAdministrator: Bool? private enum CodingKeys: String, CodingKey { case isAdministrator = "IsAdministrator" } } private enum CodingKeys: String, CodingKey { case accessToken = "AccessToken" case user = "User" } }