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