// // JellyfinClient.swift // // Copyright 2026 Brendan Szymanski // // 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 . // // SPDX-License-Identifier: GPL-3.0-or-later // import Foundation import HTTPTypes import OpenAPIRuntime import OpenAPIURLSession 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)] ) } public init(serverURL: URL, token: String, userId: String) { self.serverURL = serverURL self.token = token self.userId = userId self.clientConfig = Configuration(dateTranscoder: JellyfinDateTranscoder()) self.client = Client( serverURL: serverURL, configuration: clientConfig, transport: URLSessionTransport(), middlewares: [AuthMiddleware(token: token)] ) } public func validateToken() async -> Bool { guard let userId else { return false } do { _ = try await getUserViews(userId: userId) return true } catch { return false } } 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 .applicationJsonProfile_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 .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 .applicationJsonProfile_Quot_camelcase_quot_(let result): return result case .applicationJsonProfile_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 } }