Luminate/Sources/LuminateCore/JellyfinClient.swift

758 lines
28 KiB
Swift

//
// JellyfinClient.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 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
}
}