360 lines
13 KiB
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) }
|
|
)
|
|
}
|
|
}
|