273 lines
12 KiB
Swift
273 lines
12 KiB
Swift
//
|
|
// JellyfinClient+Auth.swift
|
|
//
|
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
//
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
|
|
import LuminateAPI
|
|
import LuminateCore
|
|
|
|
/// Sign-in, Quick Connect pairing, and password management.
|
|
extension JellyfinClient {
|
|
/// Fetches the accounts the server advertises on its login screen.
|
|
///
|
|
/// A server configured to hide its user list answers with an empty array rather than failing,
|
|
/// so an empty result means "type your username", not "no accounts exist".
|
|
///
|
|
/// - Returns: The publicly visible accounts.
|
|
/// - Throws: ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is
|
|
/// starting, or a transport error if the host is unreachable.
|
|
package func publicUsers() async throws -> [JellyfinUser] {
|
|
switch try await current.getPublicUsers(.init()) {
|
|
case .ok(let response):
|
|
return response.body.payload.map(JellyfinUser.init)
|
|
case .serviceUnavailable(let response):
|
|
throw JellyfinClientError.serviceUnavailable(
|
|
retryAfterSeconds: response.headers.retryAfter
|
|
)
|
|
case .undocumented(let statusCode, _):
|
|
throw JellyfinClientError.unexpectedStatus(
|
|
operation: Operations.GetPublicUsers.id,
|
|
statusCode: statusCode
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Signs in with a username and password and stores the resulting token on this client.
|
|
///
|
|
/// Every later call through this client is authenticated as the returned account, so a caller
|
|
/// does not have to plumb the token back in.
|
|
///
|
|
/// Jellyfin answers bad credentials with an undocumented 401, which surfaces as
|
|
/// ``JellyfinClientError/unexpectedStatus(operation:statusCode:)`` rather than
|
|
/// ``JellyfinClientError/unauthorized``: the specification declares no 401 for this operation.
|
|
///
|
|
/// - Parameters:
|
|
/// - username: The account name. Must not be empty.
|
|
/// - password: The account password. May be empty; Jellyfin permits passwordless accounts.
|
|
/// - Returns: The issued token and the account it belongs to.
|
|
/// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `username` is empty, or
|
|
/// ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the token.
|
|
package func authenticate(username: String, password: String) async throws -> JellyfinAuthentication {
|
|
guard !username.isEmpty else {
|
|
throw JellyfinClientError.invalidArgument(name: "username")
|
|
}
|
|
let input = Operations.AuthenticateUserByName.Input(
|
|
body: .json(.init(value1: Components.Schemas.AuthenticateUserByName(username: username, pw: password)))
|
|
)
|
|
switch try await current.authenticateUserByName(input) {
|
|
case .ok(let response):
|
|
return try await store(response.body.payload, from: Operations.AuthenticateUserByName.id)
|
|
case .serviceUnavailable(let response):
|
|
throw JellyfinClientError.serviceUnavailable(
|
|
retryAfterSeconds: response.headers.retryAfter
|
|
)
|
|
case .undocumented(let statusCode, _):
|
|
throw JellyfinClientError.unexpectedStatus(
|
|
operation: Operations.AuthenticateUserByName.id,
|
|
statusCode: statusCode
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Reports whether the server allows Quick Connect pairing.
|
|
///
|
|
/// - Returns: `true` when Quick Connect is enabled.
|
|
/// - Throws: ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is
|
|
/// starting, or a transport error if the host is unreachable.
|
|
package func quickConnectEnabled() async throws -> Bool {
|
|
switch try await current.getQuickConnectEnabled(.init()) {
|
|
case .ok(let response):
|
|
return response.body.payload
|
|
case .serviceUnavailable(let response):
|
|
throw JellyfinClientError.serviceUnavailable(
|
|
retryAfterSeconds: response.headers.retryAfter
|
|
)
|
|
case .undocumented(let statusCode, _):
|
|
throw JellyfinClientError.unexpectedStatus(
|
|
operation: Operations.GetQuickConnectEnabled.id,
|
|
statusCode: statusCode
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Starts a Quick Connect pairing attempt.
|
|
///
|
|
/// Show the returned ``JellyfinQuickConnectState/code`` to the user, keep the
|
|
/// ``JellyfinQuickConnectState/secret`` private, and poll ``quickConnectState(secret:)`` until
|
|
/// it reports the request authenticated.
|
|
///
|
|
/// - Returns: The new pairing request.
|
|
/// - Throws: ``JellyfinClientError/unauthorized`` if the server has Quick Connect disabled.
|
|
package func initiateQuickConnect() async throws -> JellyfinQuickConnectState {
|
|
switch try await current.initiateQuickConnect(.init()) {
|
|
case .ok(let response):
|
|
return JellyfinQuickConnectState(response.body.payload)
|
|
case .unauthorized:
|
|
throw JellyfinClientError.unauthorized
|
|
case .serviceUnavailable(let response):
|
|
throw JellyfinClientError.serviceUnavailable(
|
|
retryAfterSeconds: response.headers.retryAfter
|
|
)
|
|
case .undocumented(let statusCode, _):
|
|
throw JellyfinClientError.unexpectedStatus(
|
|
operation: Operations.InitiateQuickConnect.id,
|
|
statusCode: statusCode
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Polls an in-flight Quick Connect request.
|
|
///
|
|
/// - Parameter secret: The secret from ``initiateQuickConnect()``. Must not be empty.
|
|
/// - Returns: The current state of the request.
|
|
/// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `secret` is empty, or
|
|
/// ``JellyfinClientError/notFound`` once the server has expired the request.
|
|
package func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState {
|
|
guard !secret.isEmpty else {
|
|
throw JellyfinClientError.invalidArgument(name: "secret")
|
|
}
|
|
switch try await current.getQuickConnectState(.init(query: .init(secret: secret))) {
|
|
case .ok(let response):
|
|
return JellyfinQuickConnectState(response.body.payload)
|
|
case .notFound:
|
|
throw JellyfinClientError.notFound
|
|
case .serviceUnavailable(let response):
|
|
throw JellyfinClientError.serviceUnavailable(
|
|
retryAfterSeconds: response.headers.retryAfter
|
|
)
|
|
case .undocumented(let statusCode, _):
|
|
throw JellyfinClientError.unexpectedStatus(
|
|
operation: Operations.GetQuickConnectState.id,
|
|
statusCode: statusCode
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Redeems an approved Quick Connect secret and stores the resulting token on this client.
|
|
///
|
|
/// - Parameter secret: The secret from ``initiateQuickConnect()``. Must not be empty.
|
|
/// - Returns: The issued token and the account it belongs to.
|
|
/// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `secret` is empty,
|
|
/// ``JellyfinClientError/badRequest`` if the request was never approved, or
|
|
/// ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the token.
|
|
package func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication {
|
|
guard !secret.isEmpty else {
|
|
throw JellyfinClientError.invalidArgument(name: "secret")
|
|
}
|
|
let input = Operations.AuthenticateWithQuickConnect.Input(
|
|
body: .json(.init(value1: Components.Schemas.QuickConnectDto(secret: secret)))
|
|
)
|
|
switch try await current.authenticateWithQuickConnect(input) {
|
|
case .ok(let response):
|
|
return try await store(response.body.payload, from: Operations.AuthenticateWithQuickConnect.id)
|
|
case .badRequest:
|
|
throw JellyfinClientError.badRequest
|
|
case .serviceUnavailable(let response):
|
|
throw JellyfinClientError.serviceUnavailable(
|
|
retryAfterSeconds: response.headers.retryAfter
|
|
)
|
|
case .undocumented(let statusCode, _):
|
|
throw JellyfinClientError.unexpectedStatus(
|
|
operation: Operations.AuthenticateWithQuickConnect.id,
|
|
statusCode: statusCode
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Changes or resets an account password.
|
|
///
|
|
/// Also serves the server-mandated forced password change: pass the current password alongside
|
|
/// the new one.
|
|
///
|
|
/// - Parameters:
|
|
/// - userID: The account to change; `nil` means the signed-in account.
|
|
/// - currentPassword: The existing password, required unless resetting.
|
|
/// - currentPIN: The existing numeric Easy PIN, when the account uses one.
|
|
/// - newPassword: The replacement password.
|
|
/// - resetPassword: Pass `true` to clear the password instead of setting a new one.
|
|
/// - Throws: ``JellyfinClientError/unauthorized`` if the current password is wrong,
|
|
/// ``JellyfinClientError/forbidden`` if the account may not change its own password, or
|
|
/// ``JellyfinClientError/notFound`` if no such account exists.
|
|
package func updateUserPassword(
|
|
userID: String?,
|
|
currentPassword: String?,
|
|
currentPIN: String?,
|
|
newPassword: String?,
|
|
resetPassword: Bool?
|
|
) async throws {
|
|
let input = Operations.UpdateUserPassword.Input(
|
|
query: .init(userId: userID),
|
|
body: .json(
|
|
.init(
|
|
value1: Components.Schemas.UpdateUserPassword(
|
|
currentPassword: currentPassword,
|
|
currentPw: currentPIN,
|
|
newPw: newPassword,
|
|
resetPassword: resetPassword
|
|
)
|
|
)
|
|
)
|
|
)
|
|
switch try await current.updateUserPassword(input) {
|
|
case .noContent:
|
|
return
|
|
case .forbidden:
|
|
throw JellyfinClientError.forbidden
|
|
case .notFound:
|
|
throw JellyfinClientError.notFound
|
|
case .serviceUnavailable(let response):
|
|
throw JellyfinClientError.serviceUnavailable(
|
|
retryAfterSeconds: response.headers.retryAfter
|
|
)
|
|
case .unauthorized:
|
|
throw JellyfinClientError.unauthorized
|
|
case .undocumented(let statusCode, _):
|
|
throw JellyfinClientError.unexpectedStatus(
|
|
operation: Operations.UpdateUserPassword.id,
|
|
statusCode: statusCode
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Maps an authentication result and adopts its token as this client's credentials.
|
|
///
|
|
/// Shared by password and Quick Connect sign-in, which differ only in how they obtain the
|
|
/// result. A result with no token is treated as a failure: the server said yes but gave the
|
|
/// client nothing to authenticate with.
|
|
///
|
|
/// - Parameters:
|
|
/// - result: The generated authentication result.
|
|
/// - operation: The operation identifier, used in the thrown error.
|
|
/// - Returns: The mapped authentication.
|
|
/// - Throws: ``JellyfinClientError/missingPayload(operation:)`` if the token is absent or empty.
|
|
private func store(
|
|
_ result: Components.Schemas.AuthenticationResult,
|
|
from operation: String
|
|
) async throws -> JellyfinAuthentication {
|
|
guard let token = result.accessToken, !token.isEmpty else {
|
|
throw JellyfinClientError.missingPayload(operation: operation)
|
|
}
|
|
setAccessToken(token)
|
|
return JellyfinAuthentication(
|
|
accessToken: token,
|
|
serverID: result.serverId,
|
|
user: result.user.map { JellyfinUser($0.value1) }
|
|
)
|
|
}
|
|
}
|