diff --git a/Sources/LuminateCore/JellyfinClient.swift b/Sources/LuminateCore/JellyfinClient.swift new file mode 100644 index 0000000..39b4a7a --- /dev/null +++ b/Sources/LuminateCore/JellyfinClient.swift @@ -0,0 +1,617 @@ +import Foundation +import OpenAPIRuntime +import OpenAPIURLSession +import HTTPTypes + +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] = "MediaBrowser 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 + private var token: String? + private var client: Client + + public init(serverURL: URL) { + self.serverURL = serverURL + self.token = nil + self.client = Client( + serverURL: serverURL, + transport: URLSessionTransport() + ) + } + + private func makeClient(token: String) -> Client { + Client( + serverURL: serverURL, + 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) + 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) + 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) + 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 + } +} diff --git a/Sources/LuminateCore/LuminateCore.swift b/Sources/LuminateCore/LuminateCore.swift index df2cc20..e4556bd 100644 --- a/Sources/LuminateCore/LuminateCore.swift +++ b/Sources/LuminateCore/LuminateCore.swift @@ -1,5 +1,6 @@ -import OpenAPIURLSession +@_exported import OpenAPIRuntime +@_exported import OpenAPIURLSession -public struct LuminateCore { +public enum LuminateCore { public static let version = "0.1.0" } diff --git a/Sources/LuminateCore/openapi-generator-config.yaml b/Sources/LuminateCore/openapi-generator-config.yaml index ecefb47..736542d 100644 --- a/Sources/LuminateCore/openapi-generator-config.yaml +++ b/Sources/LuminateCore/openapi-generator-config.yaml @@ -1,3 +1,4 @@ generate: - types - client +accessModifier: public