Luminate/Sources/LuminateCore/JellyfinClient.swift

666 lines
26 KiB
Swift

import Foundation
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?
func intercept(
_ request: HTTPRequest,
body: OpenAPIRuntime.HTTPBody?,
baseURL: URL,
operationID: String,
next: (HTTPRequest, OpenAPIRuntime.HTTPBody?, URL) async throws -> (HTTPResponse, OpenAPIRuntime.HTTPBody?)
) async throws -> (HTTPResponse, OpenAPIRuntime.HTTPBody?) {
var request = request
request.headerFields[.authorization] = mediaBrowserHeader(token: token)
return try await next(request, body, baseURL)
}
}
public enum JellyfinError: Error {
case httpError(Int)
case notAuthenticated
case invalidResponse
case decodingError(String)
}
public actor JellyfinClient {
public let serverURL: URL
public private(set) var userId: String?
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,
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)]
)
}
public func authenticate(username: String, password: String) async throws -> Components.Schemas.AuthenticationResult {
let response = try await client.AuthenticateUserByName(Operations.AuthenticateUserByName.Input(
body: .json(.init(value1: .init(Username: username, Pw: password)))
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse }
token = accessToken
client = makeClient(token: accessToken)
userId = result.User?.value1.Id
return result
case .application_json_profile__quot_camelcase_quot_(let result):
guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse }
token = accessToken
client = makeClient(token: accessToken)
userId = result.User?.value1.Id
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
guard let accessToken = result.AccessToken else { throw JellyfinError.invalidResponse }
token = accessToken
client = makeClient(token: accessToken)
userId = result.User?.value1.Id
return result
}
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getItems(
userId: String,
parentId: String? = nil,
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
fields: [Components.Schemas.ItemFields]? = nil,
filters: [Components.Schemas.ItemFilter]? = nil,
sortBy: [Components.Schemas.ItemSortBy]? = nil,
sortOrder: [Components.Schemas.SortOrder]? = nil,
searchTerm: String? = nil,
startIndex: Int32? = nil,
limit: Int32? = nil,
recursive: Bool? = nil,
isFavorite: Bool? = nil
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetItems.Input.Query(userId: userId)
query.parentId = parentId
query.includeItemTypes = includeItemTypes
query.fields = fields
query.filters = filters
query.sortBy = sortBy
query.sortOrder = sortOrder
query.searchTerm = searchTerm
query.startIndex = startIndex
query.limit = limit
query.recursive = recursive
query.isFavorite = isFavorite
let response = try await client.GetItems(Operations.GetItems.Input(query: query))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getItem(itemId: String, userId: String? = nil) async throws -> Components.Schemas.BaseItemDto {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.GetItem(Operations.GetItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getUserViews(userId: String) async throws -> Components.Schemas.BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.GetUserViews(Operations.GetUserViews.Input(
query: .init(userId: userId)
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getNextUp(
userId: String,
startIndex: Int32? = nil,
limit: Int32? = nil,
fields: [Components.Schemas.ItemFields]? = nil,
seriesId: String? = nil,
parentId: String? = nil,
enableResumable: Bool? = nil,
enableRewatching: Bool? = nil
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetNextUp.Input.Query(userId: userId)
query.startIndex = startIndex
query.limit = limit
query.fields = fields
query.seriesId = seriesId
query.parentId = parentId
query.enableResumable = enableResumable
query.enableRewatching = enableRewatching
let response = try await client.GetNextUp(Operations.GetNextUp.Input(query: query))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getSeasons(
seriesId: String,
userId: String,
fields: [Components.Schemas.ItemFields]? = nil,
isSpecialSeason: Bool? = nil,
enableImages: Bool? = nil,
imageTypeLimit: Int32? = nil,
enableImageTypes: [Components.Schemas.ImageType]? = nil,
enableUserData: Bool? = nil
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetSeasons.Input.Query(userId: userId)
query.fields = fields
query.isSpecialSeason = isSpecialSeason
query.enableImages = enableImages
query.imageTypeLimit = imageTypeLimit
query.enableImageTypes = enableImageTypes
query.enableUserData = enableUserData
let response = try await client.GetSeasons(Operations.GetSeasons.Input(
path: .init(seriesId: seriesId),
query: query
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .notFound:
throw JellyfinError.httpError(404)
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getEpisodes(
seriesId: String,
userId: String,
seasonId: String? = nil,
season: Int32? = nil,
fields: [Components.Schemas.ItemFields]? = nil,
startIndex: Int32? = nil,
limit: Int32? = nil,
enableImages: Bool? = nil,
imageTypeLimit: Int32? = nil,
enableImageTypes: [Components.Schemas.ImageType]? = nil,
enableUserData: Bool? = nil
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetEpisodes.Input.Query(userId: userId)
query.seasonId = seasonId
query.season = season
query.fields = fields
query.startIndex = startIndex
query.limit = limit
query.enableImages = enableImages
query.imageTypeLimit = imageTypeLimit
query.enableImageTypes = enableImageTypes
query.enableUserData = enableUserData
let response = try await client.GetEpisodes(Operations.GetEpisodes.Input(
path: .init(seriesId: seriesId),
query: query
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .notFound:
throw JellyfinError.httpError(404)
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getSearchHints(
searchTerm: String,
userId: String? = nil,
startIndex: Int32? = nil,
limit: Int32? = nil,
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
parentId: String? = nil
) async throws -> Components.Schemas.SearchHintResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetSearchHints.Input.Query(searchTerm: searchTerm)
query.userId = userId
query.startIndex = startIndex
query.limit = limit
query.includeItemTypes = includeItemTypes
query.parentId = parentId
let response = try await client.GetSearchHints(Operations.GetSearchHints.Input(query: query))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func markPlayedItem(itemId: String, userId: String, datePlayed: Date? = nil) async throws -> Components.Schemas.UserItemDataDto {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.MarkPlayedItem(Operations.MarkPlayedItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId, datePlayed: datePlayed)
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .notFound:
throw JellyfinError.httpError(404)
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func markUnplayedItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.MarkUnplayedItem(Operations.MarkUnplayedItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .notFound:
throw JellyfinError.httpError(404)
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func markFavoriteItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.MarkFavoriteItem(Operations.MarkFavoriteItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func unmarkFavoriteItem(itemId: String, userId: String) async throws -> Components.Schemas.UserItemDataDto {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.UnmarkFavoriteItem(Operations.UnmarkFavoriteItem.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getPlaybackInfo(itemId: String, userId: String) async throws -> Components.Schemas.PlaybackInfoResponse {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.GetPlaybackInfo(Operations.GetPlaybackInfo.Input(
path: .init(itemId: itemId),
query: .init(userId: userId)
))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .notFound:
throw JellyfinError.httpError(404)
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func reportPlaybackStart(info: Components.Schemas.PlaybackStartInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.ReportPlaybackStart(Operations.ReportPlaybackStart.Input(
body: .json(.init(value1: info))
))
switch response {
case .noContent:
return
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func reportPlaybackProgress(info: Components.Schemas.PlaybackProgressInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.ReportPlaybackProgress(Operations.ReportPlaybackProgress.Input(
body: .json(.init(value1: info))
))
switch response {
case .noContent:
return
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func reportPlaybackStopped(info: Components.Schemas.PlaybackStopInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.ReportPlaybackStopped(Operations.ReportPlaybackStopped.Input(
body: .json(.init(value1: info))
))
switch response {
case .noContent:
return
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func getLatestMedia(
userId: String,
parentId: String? = nil,
fields: [Components.Schemas.ItemFields]? = nil,
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
limit: Int32? = nil,
enableImages: Bool? = nil,
imageTypeLimit: Int32? = nil,
enableImageTypes: [Components.Schemas.ImageType]? = nil,
enableUserData: Bool? = nil,
groupItems: Bool? = nil
) async throws -> [Components.Schemas.BaseItemDto] {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetLatestMedia.Input.Query(userId: userId)
query.parentId = parentId
query.fields = fields
query.includeItemTypes = includeItemTypes
query.limit = limit
query.enableImages = enableImages
query.imageTypeLimit = imageTypeLimit
query.enableImageTypes = enableImageTypes
query.enableUserData = enableUserData
query.groupItems = groupItems
let response = try await client.GetLatestMedia(Operations.GetLatestMedia.Input(query: query))
switch response {
case .ok(let ok):
switch ok.body {
case .json(let result):
return result
case .application_json_profile__quot_camelcase_quot_(let result):
return result
case .application_json_profile__quot_pascalcase_quot_(let result):
return result
}
case .unauthorized:
throw JellyfinError.httpError(401)
case .forbidden:
throw JellyfinError.httpError(403)
case .serviceUnavailable:
throw JellyfinError.httpError(503)
case .undocumented(let code, _):
throw JellyfinError.httpError(code)
}
}
public func imageURL(itemId: String, imageType: Components.Schemas.ImageType, tag: String? = nil, maxWidth: Int32? = nil, quality: Int32? = 90) -> URL? {
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { return nil }
components.path = "/Items/\(itemId)/Images/\(imageType)"
var queryItems: [URLQueryItem] = []
if let tag { queryItems.append(.init(name: "tag", value: tag)) }
if let maxWidth { queryItems.append(.init(name: "maxWidth", value: "\(maxWidth)")) }
if let quality { queryItems.append(.init(name: "quality", value: "\(quality)")) }
components.queryItems = queryItems.isEmpty ? nil : queryItems
return components.url
}
public func userImageURL(userId: String, tag: String? = nil) -> URL? {
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { return nil }
components.path = "/Users/\(userId)/Images/Primary"
if let tag { components.queryItems = [.init(name: "tag", value: tag)] }
return components.url
}
}