luminate/Sources/LuminateServices/Session/JellyfinDomainMapping.swift

360 lines
13 KiB
Swift

//
// JellyfinDomainMapping.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
// Translates generated Jellyfin DTOs into Luminate's domain models, and domain queries back into
// generated query parameters.
//
// This file is the only place the two vocabularies meet, so regenerating the OpenAPI client can
// only break here -- never in a feature target.
//
// Enum translation is by raw value and is deliberately lossy: a server enum value Luminate does not
// model becomes `nil`, or is dropped from a collection, rather than throwing. A Jellyfin release
// that adds a new item kind or image type must not break browsing.
// MARK: - Enum bridging
extension Array where Element == JellyfinItemField {
/// The generated field list, dropping any field the current specification does not define.
var generated: [Components.Schemas.ItemFields] {
compactMap { Components.Schemas.ItemFields(rawValue: $0.rawValue) }
}
}
extension Array where Element == JellyfinMediaKind {
/// The generated item-kind list, dropping any kind the current specification does not define.
var generated: [Components.Schemas.BaseItemKind] {
compactMap { Components.Schemas.BaseItemKind(rawValue: $0.rawValue) }
}
}
extension Array where Element == JellyfinImageType {
/// The generated image-type list, dropping any type the current specification does not define.
var generated: [Components.Schemas.ImageType] {
compactMap { Components.Schemas.ImageType(rawValue: $0.rawValue) }
}
}
extension Array where Element == JellyfinSortField {
/// The generated sort-field list, dropping any field the current specification does not define.
var generated: [Components.Schemas.ItemSortBy] {
compactMap { Components.Schemas.ItemSortBy(rawValue: $0.rawValue) }
}
}
extension Array where Element == JellyfinSortOrder {
/// The generated sort-order list.
var generated: [Components.Schemas.SortOrder] {
compactMap { Components.Schemas.SortOrder(rawValue: $0.rawValue) }
}
}
// MARK: - DTO to domain
extension JellyfinUser {
/// Maps a generated user DTO, keeping only the fields Luminate's login and profile flows use.
///
/// - Parameter dto: The generated user.
init(_ dto: Components.Schemas.UserDto) {
self.init(
id: dto.id,
name: dto.name,
serverID: dto.serverId,
primaryImageTag: dto.primaryImageTag,
hasPassword: dto.hasPassword,
hasConfiguredPassword: dto.hasConfiguredPassword,
hasConfiguredEasyPassword: dto.hasConfiguredEasyPassword,
lastLoginDate: dto.lastLoginDate,
primaryImageAspectRatio: dto.primaryImageAspectRatio
)
}
}
extension JellyfinServerInfo {
/// Maps the pre-auth server information.
///
/// - Parameter dto: The generated public system information.
init(_ dto: Components.Schemas.PublicSystemInfo) {
self.init(
id: dto.id,
serverName: dto.serverName,
version: dto.version,
productName: dto.productName,
operatingSystem: dto.operatingSystem,
localAddress: dto.localAddress,
startupWizardCompleted: dto.startupWizardCompleted
)
}
/// Maps the authenticated server information, keeping the fields the public response also has.
///
/// - Parameter dto: The generated system information.
init(_ dto: Components.Schemas.SystemInfo) {
self.init(
id: dto.id,
serverName: dto.serverName,
version: dto.version,
productName: dto.productName,
operatingSystem: dto.operatingSystem,
localAddress: dto.localAddress,
startupWizardCompleted: dto.startupWizardCompleted
)
}
}
extension JellyfinUserData {
/// Maps a generated per-user item state.
///
/// - Parameter dto: The generated user item data.
init(_ dto: Components.Schemas.UserItemDataDto) {
self.init(
itemID: dto.itemId,
key: dto.key,
rating: dto.rating,
playedPercentage: dto.playedPercentage,
unplayedItemCount: dto.unplayedItemCount,
playbackPositionTicks: dto.playbackPositionTicks,
playCount: dto.playCount,
isFavorite: dto.isFavorite,
likes: dto.likes,
lastPlayedDate: dto.lastPlayedDate,
played: dto.played
)
}
}
extension JellyfinMediaItem {
/// Maps a generated item DTO.
///
/// Fields the originating query did not request stay `nil`. Item kinds, collection types, and
/// image types the current Luminate build does not model are dropped rather than raised as
/// errors, so an unfamiliar server value degrades one row instead of failing the whole screen.
///
/// - Parameter dto: The generated item.
init(_ dto: Components.Schemas.BaseItemDto) {
self.init(
id: dto.id,
name: dto.name,
originalTitle: dto.originalTitle,
serverID: dto.serverId,
kind: dto._type.flatMap { JellyfinMediaKind(rawValue: $0.value1.rawValue) },
collectionType: dto.collectionType.flatMap { JellyfinCollectionType(rawValue: $0.value1.rawValue) },
overview: dto.overview,
taglines: dto.taglines,
genres: dto.genres,
officialRating: dto.officialRating,
communityRating: dto.communityRating,
criticRating: dto.criticRating,
productionYear: dto.productionYear,
premiereDate: dto.premiereDate,
dateCreated: dto.dateCreated,
runTimeTicks: dto.runTimeTicks,
indexNumber: dto.indexNumber,
parentIndexNumber: dto.parentIndexNumber,
parentID: dto.parentId,
seriesName: dto.seriesName,
seriesID: dto.seriesId,
seasonID: dto.seasonId,
seasonName: dto.seasonName,
childCount: dto.childCount,
width: dto.width,
height: dto.height,
primaryImageAspectRatio: dto.primaryImageAspectRatio,
imageTags: Self.imageTags(from: dto.imageTags),
backdropImageTags: dto.backdropImageTags ?? [],
userData: dto.userData.map { JellyfinUserData($0.value1) }
)
}
/// Rekeys the server's string-keyed image tag map by ``JellyfinImageType``.
///
/// - Parameter payload: The generated image tag container, if the item had one.
/// - Returns: The tags Luminate can name, with unrecognised keys dropped.
private static func imageTags(
from payload: Components.Schemas.BaseItemDto.ImageTagsPayload?
) -> [JellyfinImageType: String] {
guard let payload else { return [:] }
var tags: [JellyfinImageType: String] = [:]
tags.reserveCapacity(payload.additionalProperties.count)
for (key, value) in payload.additionalProperties {
guard let type = JellyfinImageType(rawValue: key) else { continue }
tags[type] = value
}
return tags
}
}
extension JellyfinLibrary {
/// Maps a generated item DTO that represents a library folder.
///
/// Libraries come back as ordinary items, but a library row needs far less than a media row, so
/// this mapper is deliberately narrower than ``JellyfinMediaItem/init(_:)``.
///
/// - Parameter dto: The generated library folder.
init(_ dto: Components.Schemas.BaseItemDto) {
self.init(
id: dto.id,
name: dto.name,
collectionType: dto.collectionType.flatMap { JellyfinCollectionType(rawValue: $0.value1.rawValue) },
overview: dto.overview,
primaryImageTag: dto.imageTags?.additionalProperties[JellyfinImageType.primary.rawValue],
backdropImageTags: dto.backdropImageTags ?? [],
childCount: dto.childCount
)
}
}
extension JellyfinMediaPage {
/// Maps a generated paged query result.
///
/// - Parameter dto: The generated query result; a `nil` item list becomes an empty page.
init(_ dto: Components.Schemas.BaseItemDtoQueryResult) {
self.init(
items: (dto.items ?? []).map(JellyfinMediaItem.init),
totalRecordCount: dto.totalRecordCount,
startIndex: dto.startIndex
)
}
}
extension JellyfinSearchHint {
/// Maps a generated search hint.
///
/// - Parameter dto: The generated hint.
init(_ dto: Components.Schemas.SearchHint) {
self.init(
itemID: dto.itemId,
id: dto.id,
name: dto.name,
matchedTerm: dto.matchedTerm,
kind: dto._type.flatMap { JellyfinMediaKind(rawValue: $0.value1.rawValue) },
productionYear: dto.productionYear,
indexNumber: dto.indexNumber,
parentIndexNumber: dto.parentIndexNumber,
primaryImageTag: dto.primaryImageTag,
thumbImageTag: dto.thumbImageTag,
backdropImageTag: dto.backdropImageTag,
isFolder: dto.isFolder,
runTimeTicks: dto.runTimeTicks,
series: dto.series
)
}
}
extension JellyfinQuickConnectState {
/// Maps a generated Quick Connect result.
///
/// - Parameter dto: The generated pairing state.
init(_ dto: Components.Schemas.QuickConnectResult) {
self.init(
authenticated: dto.authenticated,
secret: dto.secret,
code: dto.code,
deviceID: dto.deviceId,
deviceName: dto.deviceName,
appName: dto.appName,
appVersion: dto.appVersion,
dateAdded: dto.dateAdded
)
}
}
// MARK: - Domain to query
extension Operations.GetItems.Input.Query {
/// Builds the generated item query from Luminate's domain query.
///
/// Only the parameters ``JellyfinMediaQuery`` models are set; every other generated parameter
/// is left `nil` so the server applies its own default.
///
/// - Parameter query: The domain query.
init(_ query: JellyfinMediaQuery) {
self.init(
userId: query.userID,
startIndex: query.startIndex,
limit: query.limit,
recursive: query.recursive,
searchTerm: query.searchTerm,
sortOrder: query.sortOrder?.generated,
parentId: query.parentID,
fields: query.fields?.generated,
excludeItemTypes: query.excludeItemKinds?.generated,
includeItemTypes: query.includeItemKinds?.generated,
isFavorite: query.isFavorite,
sortBy: query.sortBy?.generated,
isPlayed: query.isPlayed,
genres: query.genres,
officialRatings: query.officialRatings,
years: query.years,
enableUserData: query.enableUserData,
imageTypeLimit: query.imageTypeLimit,
enableImageTypes: query.enableImageTypes?.generated,
ids: query.ids,
nameStartsWith: query.nameStartsWith,
enableImages: query.enableImages
)
}
}
extension Operations.GetItemImage.Input.Query {
/// Builds the generated image query from Luminate's domain request.
///
/// - Parameter request: The requested rendition.
init(_ request: JellyfinImageRequest) {
self.init(
maxWidth: request.maxWidth,
maxHeight: request.maxHeight,
width: request.width,
height: request.height,
quality: request.quality,
fillWidth: request.fillWidth,
fillHeight: request.fillHeight,
tag: request.tag,
format: request.format
.flatMap { Components.Schemas.ImageFormat(rawValue: $0.rawValue) }
.map { FormatPayload(value1: $0) }
)
}
}
extension Operations.GetItemImageByIndex.Input.Query {
/// Builds the generated indexed-image query from Luminate's domain request.
///
/// - Parameter request: The requested rendition.
init(_ request: JellyfinImageRequest) {
self.init(
maxWidth: request.maxWidth,
maxHeight: request.maxHeight,
width: request.width,
height: request.height,
quality: request.quality,
fillWidth: request.fillWidth,
fillHeight: request.fillHeight,
tag: request.tag,
format: request.format
.flatMap { Components.Schemas.ImageFormat(rawValue: $0.rawValue) }
.map { FormatPayload(value1: $0) }
)
}
}