luminate/Sources/LuminateServices/Library/JellyfinClient+Images.swift

144 lines
5.8 KiB
Swift

//
// JellyfinClient+Images.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 LuminateAPI
import LuminateCore
import OpenAPIRuntime
/// Artwork downloads.
extension JellyfinClient {
/// Downloads one artwork image, fully buffered.
///
/// The server renders artwork on demand, so ask for the size that will actually be drawn rather
/// than the original. Passing ``JellyfinImageRequest/tag`` lets caches key the response so a
/// replaced poster is never served stale.
///
/// The body is collected with a 25 MiB ceiling. No poster, backdrop, or logo comes close; a
/// larger body raises the OpenAPI runtime's own oversize error rather than a
/// ``JellyfinClientError``, because that is a server misconfiguration and not a Jellyfin status.
///
/// - Parameters:
/// - itemID: The item that owns the artwork. Must not be empty.
/// - type: Which artwork slot to fetch.
/// - index: Which image within the slot; `nil` means the first.
/// - request: The rendition to ask the server for.
/// - Returns: The encoded image bytes.
/// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty or the current
/// specification does not define `type`, or ``JellyfinClientError/notFound`` if the item has
/// no image in that slot.
package func image(
itemID: String,
type: JellyfinImageType,
index: Int32?,
request: JellyfinImageRequest
) async throws -> Data {
guard !itemID.isEmpty else {
throw JellyfinClientError.invalidArgument(name: "itemID")
}
guard let imageType = Components.Schemas.ImageType(rawValue: type.rawValue) else {
throw JellyfinClientError.invalidArgument(name: "type")
}
guard let index else {
return try await firstImage(itemID: itemID, imageType: imageType, request: request)
}
return try await indexedImage(itemID: itemID, imageType: imageType, index: index, request: request)
}
/// Downloads the first image in a slot.
///
/// - Parameters:
/// - itemID: The item that owns the artwork.
/// - imageType: The generated artwork slot.
/// - request: The rendition to ask the server for.
/// - Returns: The encoded image bytes.
/// - Throws: ``JellyfinClientError/notFound`` if the item has no image in that slot.
private func firstImage(
itemID: String,
imageType: Components.Schemas.ImageType,
request: JellyfinImageRequest
) async throws -> Data {
let input = Operations.GetItemImage.Input(
path: .init(itemId: itemID, imageType: .init(value1: imageType)),
query: .init(request)
)
switch try await current.getItemImage(input) {
case .ok(let response):
switch response.body {
case .image_Ast_(let body):
return try await Data(collecting: body, upTo: Self.maximumImageBytes)
}
case .notFound:
throw JellyfinClientError.notFound
case .serviceUnavailable(let response):
throw JellyfinClientError.serviceUnavailable(
retryAfterSeconds: response.headers.retryAfter
)
case .undocumented(let statusCode, _):
throw JellyfinClientError.unexpectedStatus(
operation: Operations.GetItemImage.id,
statusCode: statusCode
)
}
}
/// Downloads a specific image within a slot.
///
/// - Parameters:
/// - itemID: The item that owns the artwork.
/// - imageType: The generated artwork slot.
/// - index: Which image within the slot.
/// - request: The rendition to ask the server for.
/// - Returns: The encoded image bytes.
/// - Throws: ``JellyfinClientError/notFound`` if the item has no image at that index.
private func indexedImage(
itemID: String,
imageType: Components.Schemas.ImageType,
index: Int32,
request: JellyfinImageRequest
) async throws -> Data {
let input = Operations.GetItemImageByIndex.Input(
path: .init(itemId: itemID, imageType: .init(value1: imageType), imageIndex: index),
query: .init(request)
)
switch try await current.getItemImageByIndex(input) {
case .ok(let response):
switch response.body {
case .image_Ast_(let body):
return try await Data(collecting: body, upTo: Self.maximumImageBytes)
}
case .notFound:
throw JellyfinClientError.notFound
case .serviceUnavailable(let response):
throw JellyfinClientError.serviceUnavailable(
retryAfterSeconds: response.headers.retryAfter
)
case .undocumented(let statusCode, _):
throw JellyfinClientError.unexpectedStatus(
operation: Operations.GetItemImageByIndex.id,
statusCode: statusCode
)
}
}
/// The largest artwork response Luminate will buffer, in bytes.
private static var maximumImageBytes: Int { 25 * 1024 * 1024 }
}