diff --git a/Package.swift b/Package.swift index d5e3c7e..300d8e0 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,23 @@ let uiSwiftSettings: [SwiftSetting] = strictConcurrencySettings + [ .defaultIsolation(MainActor.self), ] +// Everything the service layer needs to speak to a Jellyfin server: the domain +// layer, the generated client, the OpenAPI runtime/transport, and HTTPTypes for +// the middleware's own HTTPRequest/HTTPResponse signature. +let servicesDeps: [Target.Dependency] = [ + "LuminateCore", + "LuminateAPI", + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), +] + +// UI-layer targets see the domain layer and Portico, never the generated client. +let uiDeps: [Target.Dependency] = [ + "LuminateCore", + .product(name: "Portico", package: "portico"), +] + let package = Package( name: "luminate", platforms: [.macOS(.v26)], @@ -26,6 +43,7 @@ let package = Package( ], dependencies: [ .package(url: "https://git.bscubed.dev/gtk-swift/portico.git", branch: "main"), + .package(url: "https://github.com/apple/swift-http-types", from: "1.6.0"), .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.13.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.12.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.3.1"), @@ -33,6 +51,10 @@ let package = Package( targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "LuminateCore", + swiftSettings: strictConcurrencySettings + ), .target( name: "LuminateAPI", dependencies: [ @@ -44,20 +66,39 @@ let package = Package( .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator") ] ), + .target( + name: "LuminateServices", + dependencies: servicesDeps, + swiftSettings: strictConcurrencySettings + ), + .target( + name: "LuminateUI", + dependencies: uiDeps, + swiftSettings: uiSwiftSettings + ), .executableTarget( name: "Luminate", dependencies: [ - "LuminateAPI", - .product(name: "Portico", package: "portico") + "LuminateCore", + "LuminateServices", + "LuminateUI", + .product(name: "Portico", package: "portico"), ], swiftSettings: uiSwiftSettings ), .testTarget( - name: "LuminateTests", - dependencies: [ - "Luminate", - .product(name: "Portico", package: "portico") - ], + name: "LuminateCoreTests", + dependencies: ["LuminateCore"], + swiftSettings: strictConcurrencySettings + ), + .testTarget( + name: "LuminateServicesTests", + dependencies: servicesDeps + ["LuminateServices"], + swiftSettings: strictConcurrencySettings + ), + .testTarget( + name: "LuminateUITests", + dependencies: uiDeps + ["LuminateUI"], swiftSettings: uiSwiftSettings ), ], diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 10fc6f3..ef53709 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -19,15 +19,44 @@ // SPDX-License-Identifier: GPL-3.0-or-later // +import Foundation +import LuminateServices +import LuminateUI import Portico +/// The Luminate application entry point. +/// +/// Owns the single ``JellyfinClient`` for the process and publishes it to the whole view tree +/// through the `\.client` environment slot, so no screen constructs its own client. @main struct Luminate: App { var applicationId: String? { "dev.bscubed.Luminate" } - + + /// The process-wide Jellyfin service. + /// + /// Starts pointed at `http://localhost`, which is the server URL the Jellyfin specification + /// declares. Onboarding replaces it with the user's chosen server through + /// ``JellyfinClient/setServerURL(_:)``; nothing here issues a request. + private let jellyfin = JellyfinClient( + configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!) + ) + var body: some Scene { ApplicationWindow { _ in - Label("Hello, world!") + RootView() + .environment(\.client, jellyfin) } } -} \ No newline at end of file +} + +/// The root of the window's view tree. +/// +/// Reads the injected client so a launch proves the `\.client` slot resolves end to end; Portico +/// traps at mount if a key-path slot cannot be resolved. +private struct RootView: View { + @Environment(\.client) private var jellyfinClient + + var body: some View { + Label("Hello, world!") + } +} diff --git a/Sources/LuminateCore/Errors/JellyfinClientError.swift b/Sources/LuminateCore/Errors/JellyfinClientError.swift new file mode 100644 index 0000000..32449e2 --- /dev/null +++ b/Sources/LuminateCore/Errors/JellyfinClientError.swift @@ -0,0 +1,61 @@ +// +// JellyfinClientError.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 +// + +/// A failure Luminate's Jellyfin service layer raises on its own behalf. +/// +/// This covers argument validation, the HTTP statuses the Jellyfin API documents, and responses +/// that arrive without the payload the spec promises. Transport failures, decoding failures, and +/// oversized response bodies are *not* wrapped: those propagate from the OpenAPI runtime unchanged +/// so their diagnostics survive. +package enum JellyfinClientError: Error, Hashable, Sendable { + /// No server has been configured for this service. + /// + /// Thrown by the placeholder service that fills the `\.client` environment slot when nothing + /// was injected, so a missing `.environment(\.client, ...)` fails loudly at the first call + /// instead of quietly talking to the wrong host. + case notConfigured + /// A required argument was empty or otherwise unusable, so no request was sent. + /// + /// - Parameter name: The Swift parameter name that was rejected. + case invalidArgument(name: String) + /// The server rejected the credentials, or the access token expired. Sign in again. + case unauthorized + /// The account is authenticated but not permitted to perform the operation. + case forbidden + /// The requested item, image, or user does not exist on the server. + case notFound + /// The server rejected the request as malformed. + case badRequest + /// The server is starting up or otherwise temporarily unavailable. + /// + /// - Parameter retryAfterSeconds: How long the server asked the client to wait, when it said. + case serviceUnavailable(retryAfterSeconds: Int32?) + /// The server answered with a status the API specification does not document. + /// + /// - Parameters: + /// - operation: The Jellyfin operation identifier, such as `GetItems`. + /// - statusCode: The HTTP status code received. + case unexpectedStatus(operation: String, statusCode: Int) + /// The server answered successfully but omitted a value the operation cannot proceed without. + /// + /// - Parameter operation: The Jellyfin operation identifier, such as `AuthenticateUserByName`. + case missingPayload(operation: String) +} diff --git a/Sources/LuminateCore/Models/JellyfinAuthentication.swift b/Sources/LuminateCore/Models/JellyfinAuthentication.swift new file mode 100644 index 0000000..3dcd1a7 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinAuthentication.swift @@ -0,0 +1,45 @@ +// +// JellyfinAuthentication.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 +// + +/// The result of a successful sign-in. +/// +/// `accessToken` is non-optional: a login that produced no token is a failure, so the service layer +/// throws rather than handing back a value with a missing token. +package struct JellyfinAuthentication: Hashable, Sendable { + /// The bearer token to send on every subsequent request. + package var accessToken: String + /// The identifier of the server that issued the token. + package var serverID: String? + /// The account the token belongs to. + package var user: JellyfinUser? + + /// Creates an authentication result. + /// + /// - Parameters: + /// - accessToken: The token issued by the server. + /// - serverID: The identifier of the issuing server. + /// - user: The signed-in account. + package init(accessToken: String, serverID: String? = nil, user: JellyfinUser? = nil) { + self.accessToken = accessToken + self.serverID = serverID + self.user = user + } +} diff --git a/Sources/LuminateCore/Models/JellyfinCollectionType.swift b/Sources/LuminateCore/Models/JellyfinCollectionType.swift new file mode 100644 index 0000000..4ace19d --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinCollectionType.swift @@ -0,0 +1,38 @@ +// +// JellyfinCollectionType.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 +// + +/// The kinds of library a Jellyfin server exposes to Luminate. +/// +/// Raw values are the server's `CollectionType` spellings, which are lowercase on the wire. Only +/// the in-scope library types have cases; a music, book, or live-TV library decodes as `nil`, which +/// is how Luminate filters out-of-scope libraries without special-casing them at each call site. +package enum JellyfinCollectionType: String, CaseIterable, Hashable, Sendable { + /// A movie library. + case movies = "movies" + /// A television library. + case tvShows = "tvshows" + /// A library of box sets. + case boxSets = "boxsets" + /// A library of playlists. + case playlists = "playlists" + /// A library of plain folders. + case folders = "folders" +} diff --git a/Sources/LuminateCore/Models/JellyfinImageFormat.swift b/Sources/LuminateCore/Models/JellyfinImageFormat.swift new file mode 100644 index 0000000..f775513 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinImageFormat.swift @@ -0,0 +1,39 @@ +// +// JellyfinImageFormat.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 +// + +/// The encodings a Jellyfin server can transcode artwork into. +/// +/// Raw values are the server's `ImageFormat` spellings. Leaving the format unset on a +/// ``JellyfinImageRequest`` lets the server pick, which is what Luminate normally wants. +package enum JellyfinImageFormat: String, CaseIterable, Hashable, Sendable { + /// Windows bitmap. + case bmp = "Bmp" + /// Graphics Interchange Format. + case gif = "Gif" + /// JPEG, the server's usual choice for photographic artwork. + case jpg = "Jpg" + /// PNG, preferred for logos and other artwork with transparency. + case png = "Png" + /// WebP. + case webp = "Webp" + /// Scalable Vector Graphics. + case svg = "Svg" +} diff --git a/Sources/LuminateCore/Models/JellyfinImageRequest.swift b/Sources/LuminateCore/Models/JellyfinImageRequest.swift new file mode 100644 index 0000000..2b116f3 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinImageRequest.swift @@ -0,0 +1,82 @@ +// +// JellyfinImageRequest.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 +// + +/// The rendition to ask the server for when downloading artwork. +/// +/// The server resizes and re-encodes artwork on demand, so a caller should request the size it will +/// actually draw rather than the original. An empty request, `JellyfinImageRequest()`, asks for the +/// server's default rendition. +/// +/// Passing ``tag`` lets the server and any intermediate cache key the response, so a changed poster +/// is not served stale. +/// +/// ```swift +/// let poster = try await client.image( +/// itemID: item.id!, +/// type: .primary, +/// request: JellyfinImageRequest(tag: item.imageTags[.primary], fillWidth: 300, fillHeight: 450) +/// ) +/// ``` +package struct JellyfinImageRequest: Hashable, Sendable { + /// The image tag the item advertised, used for cache validation. + package var tag: String? + /// The encoding to transcode into; `nil` lets the server choose. + package var format: JellyfinImageFormat? + /// Scale down so the width does not exceed this many pixels. + package var maxWidth: Int32? + /// Scale down so the height does not exceed this many pixels. + package var maxHeight: Int32? + /// Scale to exactly this width in pixels. + package var width: Int32? + /// Scale to exactly this height in pixels. + package var height: Int32? + /// Scale and crop to fill exactly this width in pixels. + package var fillWidth: Int32? + /// Scale and crop to fill exactly this height in pixels. + package var fillHeight: Int32? + /// The lossy compression quality, from 0 to 100. + package var quality: Int32? + + /// Creates a request for the server's default rendition. + /// + /// Each parameter sets the property of the same name. + package init( + tag: String? = nil, + format: JellyfinImageFormat? = nil, + maxWidth: Int32? = nil, + maxHeight: Int32? = nil, + width: Int32? = nil, + height: Int32? = nil, + fillWidth: Int32? = nil, + fillHeight: Int32? = nil, + quality: Int32? = nil + ) { + self.tag = tag + self.format = format + self.maxWidth = maxWidth + self.maxHeight = maxHeight + self.width = width + self.height = height + self.fillWidth = fillWidth + self.fillHeight = fillHeight + self.quality = quality + } +} diff --git a/Sources/LuminateCore/Models/JellyfinImageType.swift b/Sources/LuminateCore/Models/JellyfinImageType.swift new file mode 100644 index 0000000..e029e62 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinImageType.swift @@ -0,0 +1,53 @@ +// +// JellyfinImageType.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 +// + +/// The artwork slots a Jellyfin item can expose. +/// +/// Raw values are the server's `ImageType` spellings. An item advertises the slots it actually has +/// through `JellyfinMediaItem.imageTags`; requesting a slot the item lacks yields a not-found error. +package enum JellyfinImageType: String, CaseIterable, Hashable, Sendable { + /// The main poster or cover art. + case primary = "Primary" + /// Supplementary key art. + case art = "Art" + /// Wide background art, typically shown behind detail pages. + case backdrop = "Backdrop" + /// A wide banner with the title burned in. + case banner = "Banner" + /// A transparent title treatment, used over backdrops. + case logo = "Logo" + /// A landscape thumbnail. + case thumb = "Thumb" + /// Disc art. + case disc = "Disc" + /// Front box art. + case box = "Box" + /// A still captured from the media itself. + case screenshot = "Screenshot" + /// Menu art. + case menu = "Menu" + /// A chapter thumbnail. + case chapter = "Chapter" + /// Rear box art. + case boxRear = "BoxRear" + /// A person's headshot. + case profile = "Profile" +} diff --git a/Sources/LuminateCore/Models/JellyfinItemField.swift b/Sources/LuminateCore/Models/JellyfinItemField.swift new file mode 100644 index 0000000..7face27 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinItemField.swift @@ -0,0 +1,78 @@ +// +// JellyfinItemField.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 +// + +/// Optional metadata a Jellyfin server only populates when explicitly asked for. +/// +/// Item responses are lean by default: overviews, genres, media streams, and similar heavy fields +/// stay `nil` unless the corresponding field is requested. Request only what a screen renders -- +/// every added field costs server work and response size. +package enum JellyfinItemField: String, CaseIterable, Hashable, Sendable { + /// The long-form synopsis. + case overview = "Overview" + /// The genres attached to the item. + case genres = "Genres" + /// Marketing taglines. + case taglines = "Taglines" + /// Free-form tags. + case tags = "Tags" + /// The producing studios. + case studios = "Studios" + /// Cast and crew. + case people = "People" + /// External database identifiers such as IMDb or TMDb. + case providerIDs = "ProviderIds" + /// The playable media sources, including container and bitrate. + case mediaSources = "MediaSources" + /// The audio, video, and subtitle streams of each media source. + case mediaStreams = "MediaStreams" + /// Chapter markers. + case chapters = "Chapters" + /// Trickplay tile metadata used for seek previews. + case trickplay = "Trickplay" + /// Whether the current user may play the item. + case playAccess = "PlayAccess" + /// The identifier of the containing item. + case parentID = "ParentId" + /// The studio of the parent series, for episodes. + case seriesStudio = "SeriesStudio" + /// The title in the original production language. + case originalTitle = "OriginalTitle" + /// How many direct children the item has. + case childCount = "ChildCount" + /// How many descendants the item has in total. + case recursiveItemCount = "RecursiveItemCount" + /// Whether the current user may delete the item. + case canDelete = "CanDelete" + /// Whether the current user may download the item. + case canDownload = "CanDownload" + /// The video width in pixels. + case width = "Width" + /// The video height in pixels. + case height = "Height" + /// Whether the item is high definition. + case isHighDefinition = "IsHD" + /// When the item was added to the library. + case dateCreated = "DateCreated" + /// The server-computed sort title. + case sortName = "SortName" + /// The aspect ratio of the primary image. + case primaryImageAspectRatio = "PrimaryImageAspectRatio" +} diff --git a/Sources/LuminateCore/Models/JellyfinLibrary.swift b/Sources/LuminateCore/Models/JellyfinLibrary.swift new file mode 100644 index 0000000..c351dd3 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinLibrary.swift @@ -0,0 +1,63 @@ +// +// JellyfinLibrary.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 +// + +/// A top-level library a user can browse. +/// +/// Deliberately narrower than ``JellyfinMediaItem``: a library row needs a name, artwork, and a +/// collection type, and nothing else. A `nil` ``collectionType`` means the server reported a +/// library type Luminate does not support, which is how music and book libraries are filtered out. +package struct JellyfinLibrary: Hashable, Sendable { + /// The server-assigned library identifier, used as the parent identifier when browsing. + package var id: String? + /// The display name of the library. + package var name: String? + /// What kind of media the library holds. + package var collectionType: JellyfinCollectionType? + /// The library description, when the server has one. + package var overview: String? + /// The image tag used to fetch the library's poster. + package var primaryImageTag: String? + /// The tags of the library's backdrop images, in server order. + package var backdropImageTags: [String] + /// How many items the library directly contains. + package var childCount: Int32? + + /// Creates a library, defaulting every unspecified field to `nil` or empty. + /// + /// Each parameter sets the property of the same name. + package init( + id: String? = nil, + name: String? = nil, + collectionType: JellyfinCollectionType? = nil, + overview: String? = nil, + primaryImageTag: String? = nil, + backdropImageTags: [String] = [], + childCount: Int32? = nil + ) { + self.id = id + self.name = name + self.collectionType = collectionType + self.overview = overview + self.primaryImageTag = primaryImageTag + self.backdropImageTags = backdropImageTags + self.childCount = childCount + } +} diff --git a/Sources/LuminateCore/Models/JellyfinListOptions.swift b/Sources/LuminateCore/Models/JellyfinListOptions.swift new file mode 100644 index 0000000..3883e94 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinListOptions.swift @@ -0,0 +1,81 @@ +// +// JellyfinListOptions.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 +// + +/// Shared options for the narrow list endpoints -- libraries, resume, next up, latest, seasons, +/// episodes, and search. +/// +/// Those endpoints each accept a small, overlapping set of knobs, and a protocol requirement cannot +/// declare default arguments. Collapsing them into one bag keeps ``JellyfinService`` readable and +/// lets call sites set only what they care about. Not every option applies to every endpoint; an +/// option an endpoint has no parameter for is ignored. +/// +/// ```swift +/// let upNext = try await client.nextUp(options: JellyfinListOptions(userID: userID, limit: 20)) +/// ``` +package struct JellyfinListOptions: Hashable, Sendable { + /// Restrict results to what this user may see, and populate their playback state. + package var userID: String? + /// Restrict results to descendants of this item, normally a library. + package var parentID: String? + /// Restrict results to this season, for the episode listing. + package var seasonID: String? + /// Restrict results to this season number, for the episode listing. + package var season: Int32? + /// Skip this many matching items before the first result. + package var startIndex: Int32? + /// Return at most this many items. + package var limit: Int32? + /// Optional metadata to populate on each returned item. + package var fields: [JellyfinItemField]? + /// Only return these kinds of item, where the endpoint supports filtering by kind. + package var includeItemKinds: [JellyfinMediaKind]? + /// Include libraries the server hides from the home screen, for the library listing. + package var includeHidden: Bool? + /// Collapse episodes of the same series into one entry, for the latest-media listing. + package var groupItems: Bool? + + /// Creates an empty option set, leaving every parameter to the server's default. + /// + /// Each parameter sets the property of the same name. + package init( + userID: String? = nil, + parentID: String? = nil, + seasonID: String? = nil, + season: Int32? = nil, + startIndex: Int32? = nil, + limit: Int32? = nil, + fields: [JellyfinItemField]? = nil, + includeItemKinds: [JellyfinMediaKind]? = nil, + includeHidden: Bool? = nil, + groupItems: Bool? = nil + ) { + self.userID = userID + self.parentID = parentID + self.seasonID = seasonID + self.season = season + self.startIndex = startIndex + self.limit = limit + self.fields = fields + self.includeItemKinds = includeItemKinds + self.includeHidden = includeHidden + self.groupItems = groupItems + } +} diff --git a/Sources/LuminateCore/Models/JellyfinMediaItem.swift b/Sources/LuminateCore/Models/JellyfinMediaItem.swift new file mode 100644 index 0000000..e0ed530 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinMediaItem.swift @@ -0,0 +1,160 @@ +// +// JellyfinMediaItem.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 + +/// A movie, series, season, or episode as Luminate renders it. +/// +/// This is the domain counterpart of the server's `BaseItemDto`, which carries well over a hundred +/// fields. Most properties are `nil` unless the originating query requested the matching +/// ``JellyfinItemField``, so a list query and a detail query return the same type at different +/// levels of detail. +package struct JellyfinMediaItem: Hashable, Sendable { + /// The server-assigned item identifier. + package var id: String? + /// The display title. + package var name: String? + /// The title in the original production language. + package var originalTitle: String? + /// The identifier of the server holding the item. + package var serverID: String? + /// What kind of item this is; `nil` when the server reported a kind Luminate does not model. + package var kind: JellyfinMediaKind? + /// The library type, set only on library folders. + package var collectionType: JellyfinCollectionType? + /// The long-form synopsis. + package var overview: String? + /// Marketing taglines. + package var taglines: [String]? + /// The genres attached to the item. + package var genres: [String]? + /// The parental rating certificate, such as `PG-13`. + package var officialRating: String? + /// The aggregate community score, out of ten. + package var communityRating: Float? + /// The aggregate critic score, out of one hundred. + package var criticRating: Float? + /// The year of production. + package var productionYear: Int32? + /// The original release date. + package var premiereDate: Date? + /// When the item was added to the library. + package var dateCreated: Date? + /// The total duration, in 100-nanosecond ticks. + package var runTimeTicks: Int64? + /// The episode number within its season, or the season number for a season. + package var indexNumber: Int32? + /// The season number, for episodes. + package var parentIndexNumber: Int32? + /// The identifier of the containing item. + package var parentID: String? + /// The title of the parent series, for seasons and episodes. + package var seriesName: String? + /// The identifier of the parent series, for seasons and episodes. + package var seriesID: String? + /// The identifier of the parent season, for episodes. + package var seasonID: String? + /// The title of the parent season, for episodes. + package var seasonName: String? + /// How many direct children the item has. + package var childCount: Int32? + /// The video width in pixels. + package var width: Int32? + /// The video height in pixels. + package var height: Int32? + /// The aspect ratio of the primary image, useful for sizing a poster before it loads. + package var primaryImageAspectRatio: Double? + /// The artwork slots this item has, keyed by type; the values are cache-busting image tags. + /// + /// Slots the server reported under a type Luminate does not model are dropped. + package var imageTags: [JellyfinImageType: String] + /// The tags of the item's backdrop images, in server order. + package var backdropImageTags: [String] + /// The requesting user's playback and favourite state, when the query enabled user data. + package var userData: JellyfinUserData? + + /// Creates a media item, defaulting every unspecified field to `nil` or empty. + /// + /// Each parameter sets the property of the same name. + package init( + id: String? = nil, + name: String? = nil, + originalTitle: String? = nil, + serverID: String? = nil, + kind: JellyfinMediaKind? = nil, + collectionType: JellyfinCollectionType? = nil, + overview: String? = nil, + taglines: [String]? = nil, + genres: [String]? = nil, + officialRating: String? = nil, + communityRating: Float? = nil, + criticRating: Float? = nil, + productionYear: Int32? = nil, + premiereDate: Date? = nil, + dateCreated: Date? = nil, + runTimeTicks: Int64? = nil, + indexNumber: Int32? = nil, + parentIndexNumber: Int32? = nil, + parentID: String? = nil, + seriesName: String? = nil, + seriesID: String? = nil, + seasonID: String? = nil, + seasonName: String? = nil, + childCount: Int32? = nil, + width: Int32? = nil, + height: Int32? = nil, + primaryImageAspectRatio: Double? = nil, + imageTags: [JellyfinImageType: String] = [:], + backdropImageTags: [String] = [], + userData: JellyfinUserData? = nil + ) { + self.id = id + self.name = name + self.originalTitle = originalTitle + self.serverID = serverID + self.kind = kind + self.collectionType = collectionType + self.overview = overview + self.taglines = taglines + self.genres = genres + self.officialRating = officialRating + self.communityRating = communityRating + self.criticRating = criticRating + self.productionYear = productionYear + self.premiereDate = premiereDate + self.dateCreated = dateCreated + self.runTimeTicks = runTimeTicks + self.indexNumber = indexNumber + self.parentIndexNumber = parentIndexNumber + self.parentID = parentID + self.seriesName = seriesName + self.seriesID = seriesID + self.seasonID = seasonID + self.seasonName = seasonName + self.childCount = childCount + self.width = width + self.height = height + self.primaryImageAspectRatio = primaryImageAspectRatio + self.imageTags = imageTags + self.backdropImageTags = backdropImageTags + self.userData = userData + } +} diff --git a/Sources/LuminateCore/Models/JellyfinMediaKind.swift b/Sources/LuminateCore/Models/JellyfinMediaKind.swift new file mode 100644 index 0000000..62120ff --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinMediaKind.swift @@ -0,0 +1,57 @@ +// +// JellyfinMediaKind.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 +// + +/// The kinds of Jellyfin library item Luminate understands. +/// +/// Raw values are the wire spellings Jellyfin uses for `BaseItemKind`, so translating to and from +/// the generated API enums is a `rawValue` round trip. Luminate is scoped to movies and TV, so +/// music, book, photo, and live-TV kinds are deliberately absent; a server value with no case here +/// decodes as `nil` rather than failing the surrounding request. +package enum JellyfinMediaKind: String, CaseIterable, Hashable, Sendable { + /// A single feature-length film. + case movie = "Movie" + /// A television series, the parent of seasons. + case series = "Series" + /// One season of a series, the parent of episodes. + case season = "Season" + /// A single episode of a season. + case episode = "Episode" + /// A user-curated collection of items, called a box set by the server. + case boxSet = "BoxSet" + /// A top-level library folder such as "Movies" or "Shows". + case collectionFolder = "CollectionFolder" + /// A plain folder inside a library. + case folder = "Folder" + /// A view the server synthesises for a user, such as "Favorites". + case userView = "UserView" + /// A cast or crew member. + case person = "Person" + /// A production studio. + case studio = "Studio" + /// A genre used to group items. + case genre = "Genre" + /// An ordered, user-managed playlist. + case playlist = "Playlist" + /// A trailer attached to a movie or series. + case trailer = "Trailer" + /// A standalone video that is not a movie or an episode. + case video = "Video" +} diff --git a/Sources/LuminateCore/Models/JellyfinMediaPage.swift b/Sources/LuminateCore/Models/JellyfinMediaPage.swift new file mode 100644 index 0000000..1ee41ff --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinMediaPage.swift @@ -0,0 +1,49 @@ +// +// JellyfinMediaPage.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 +// + +/// One page of a paged item query. +/// +/// `totalRecordCount` describes the whole result set, not this page, so it is what a paginator +/// should size itself against. +package struct JellyfinMediaPage: Hashable, Sendable { + /// The items on this page, in server order. + package var items: [JellyfinMediaItem] + /// How many items match the query in total, across every page. + package var totalRecordCount: Int32? + /// The offset of this page into the whole result set. + package var startIndex: Int32? + + /// Creates a page, defaulting to an empty result set. + /// + /// - Parameters: + /// - items: The items on this page. + /// - totalRecordCount: How many items match the query in total. + /// - startIndex: The offset of this page into the result set. + package init( + items: [JellyfinMediaItem] = [], + totalRecordCount: Int32? = nil, + startIndex: Int32? = nil + ) { + self.items = items + self.totalRecordCount = totalRecordCount + self.startIndex = startIndex + } +} diff --git a/Sources/LuminateCore/Models/JellyfinMediaQuery.swift b/Sources/LuminateCore/Models/JellyfinMediaQuery.swift new file mode 100644 index 0000000..161def8 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinMediaQuery.swift @@ -0,0 +1,133 @@ +// +// JellyfinMediaQuery.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 +// + +/// A browse or search query against a Jellyfin library. +/// +/// The server's item endpoint accepts close to ninety query parameters. This type exposes only the +/// ones Luminate's browse, search, and filter surfaces actually drive, so a spec regeneration +/// cannot ripple into feature code. Every property is optional; a `nil` property is simply not sent +/// and the server applies its own default. +/// +/// ```swift +/// var query = JellyfinMediaQuery() +/// query.parentID = library.id +/// query.includeItemKinds = [.movie] +/// query.sortBy = [.sortName] +/// query.limit = 60 +/// let page = try await client.items(query) +/// ``` +package struct JellyfinMediaQuery: Hashable, Sendable { + /// Restrict results to what this user may see, and populate their playback state. + package var userID: String? + /// Restrict results to descendants of this item, normally a library. + package var parentID: String? + /// Only return these kinds of item. + package var includeItemKinds: [JellyfinMediaKind]? + /// Never return these kinds of item. + package var excludeItemKinds: [JellyfinMediaKind]? + /// Fetch exactly these items, ignoring the other filters. + package var ids: [String]? + /// Match items against this free-text term. + package var searchTerm: String? + /// Order results by these fields, most significant first. + package var sortBy: [JellyfinSortField]? + /// The direction of each entry in ``sortBy``. + package var sortOrder: [JellyfinSortOrder]? + /// Optional metadata to populate on each returned item. + package var fields: [JellyfinItemField]? + /// Skip this many matching items before the first result. + package var startIndex: Int32? + /// Return at most this many items. + package var limit: Int32? + /// Search the whole subtree rather than only direct children. + package var recursive: Bool? + /// Only return items the user has, or has not, favourited. + package var isFavorite: Bool? + /// Only return items the user has, or has not, watched. + package var isPlayed: Bool? + /// Only return items carrying one of these genres. + package var genres: [String]? + /// Only return items produced in one of these years. + package var years: [Int32]? + /// Only return items with one of these parental rating certificates. + package var officialRatings: [String]? + /// Only return items whose sort title starts with this string, for the alphabet jump picker. + package var nameStartsWith: String? + /// Populate each item's playback and favourite state. + package var enableUserData: Bool? + /// Populate each item's artwork tags. + package var enableImages: Bool? + /// Return at most this many images per image type. + package var imageTypeLimit: Int32? + /// Only report artwork tags for these image types. + package var enableImageTypes: [JellyfinImageType]? + + /// Creates an unfiltered query, leaving every parameter to the server's default. + /// + /// Each parameter sets the property of the same name. + package init( + userID: String? = nil, + parentID: String? = nil, + includeItemKinds: [JellyfinMediaKind]? = nil, + excludeItemKinds: [JellyfinMediaKind]? = nil, + ids: [String]? = nil, + searchTerm: String? = nil, + sortBy: [JellyfinSortField]? = nil, + sortOrder: [JellyfinSortOrder]? = nil, + fields: [JellyfinItemField]? = nil, + startIndex: Int32? = nil, + limit: Int32? = nil, + recursive: Bool? = nil, + isFavorite: Bool? = nil, + isPlayed: Bool? = nil, + genres: [String]? = nil, + years: [Int32]? = nil, + officialRatings: [String]? = nil, + nameStartsWith: String? = nil, + enableUserData: Bool? = nil, + enableImages: Bool? = nil, + imageTypeLimit: Int32? = nil, + enableImageTypes: [JellyfinImageType]? = nil + ) { + self.userID = userID + self.parentID = parentID + self.includeItemKinds = includeItemKinds + self.excludeItemKinds = excludeItemKinds + self.ids = ids + self.searchTerm = searchTerm + self.sortBy = sortBy + self.sortOrder = sortOrder + self.fields = fields + self.startIndex = startIndex + self.limit = limit + self.recursive = recursive + self.isFavorite = isFavorite + self.isPlayed = isPlayed + self.genres = genres + self.years = years + self.officialRatings = officialRatings + self.nameStartsWith = nameStartsWith + self.enableUserData = enableUserData + self.enableImages = enableImages + self.imageTypeLimit = imageTypeLimit + self.enableImageTypes = enableImageTypes + } +} diff --git a/Sources/LuminateCore/Models/JellyfinQuickConnectState.swift b/Sources/LuminateCore/Models/JellyfinQuickConnectState.swift new file mode 100644 index 0000000..63ed5b9 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinQuickConnectState.swift @@ -0,0 +1,70 @@ +// +// JellyfinQuickConnectState.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 + +/// The state of a Quick Connect pairing attempt. +/// +/// Quick Connect is a three-part exchange: Luminate initiates a request and shows the returned +/// ``code`` to the user, the user approves that code from an already signed-in client, and Luminate +/// polls with the ``secret`` until ``authenticated`` is `true` and then exchanges the secret for a +/// token. The secret must never be shown to the user; the code must never be sent as a secret. +package struct JellyfinQuickConnectState: Hashable, Sendable { + /// Whether the user has approved the request yet. + package var authenticated: Bool? + /// The private value Luminate polls and finally redeems with. Never display this. + package var secret: String? + /// The short human-readable code the user types into an already signed-in client. + package var code: String? + /// The identifier of the device that made the request. + package var deviceID: String? + /// The name of the device that made the request. + package var deviceName: String? + /// The name of the application that made the request. + package var appName: String? + /// The version of the application that made the request. + package var appVersion: String? + /// When the request was created; servers expire unapproved requests. + package var dateAdded: Date? + + /// Creates a Quick Connect state, defaulting every unspecified field to `nil`. + /// + /// Each parameter sets the property of the same name. + package init( + authenticated: Bool? = nil, + secret: String? = nil, + code: String? = nil, + deviceID: String? = nil, + deviceName: String? = nil, + appName: String? = nil, + appVersion: String? = nil, + dateAdded: Date? = nil + ) { + self.authenticated = authenticated + self.secret = secret + self.code = code + self.deviceID = deviceID + self.deviceName = deviceName + self.appName = appName + self.appVersion = appVersion + self.dateAdded = dateAdded + } +} diff --git a/Sources/LuminateCore/Models/JellyfinSearchHint.swift b/Sources/LuminateCore/Models/JellyfinSearchHint.swift new file mode 100644 index 0000000..95cca59 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinSearchHint.swift @@ -0,0 +1,90 @@ +// +// JellyfinSearchHint.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 +// + +/// One suggestion from the server's search-as-you-type endpoint. +/// +/// A hint is not a full ``JellyfinMediaItem``: it carries just enough to draw a result row and then +/// navigate, using ``itemID`` to fetch the real item. +package struct JellyfinSearchHint: Hashable, Sendable { + /// The identifier of the item the hint points at. + package var itemID: String? + /// The hint's own identifier, which the server keeps distinct from the item identifier. + package var id: String? + /// The display title. + package var name: String? + /// The portion of the item's metadata that matched the search term. + package var matchedTerm: String? + /// What kind of item the hint points at. + package var kind: JellyfinMediaKind? + /// The year of production. + package var productionYear: Int32? + /// The episode number within its season. + package var indexNumber: Int32? + /// The season number, for episodes. + package var parentIndexNumber: Int32? + /// The image tag used to fetch the poster. + package var primaryImageTag: String? + /// The image tag used to fetch the landscape thumbnail. + package var thumbImageTag: String? + /// The image tag used to fetch the backdrop. + package var backdropImageTag: String? + /// Whether the hint points at a folder rather than a playable item. + package var isFolder: Bool? + /// The total duration, in 100-nanosecond ticks. + package var runTimeTicks: Int64? + /// The title of the parent series, for episodes. + package var series: String? + + /// Creates a search hint, defaulting every unspecified field to `nil`. + /// + /// Each parameter sets the property of the same name. + package init( + itemID: String? = nil, + id: String? = nil, + name: String? = nil, + matchedTerm: String? = nil, + kind: JellyfinMediaKind? = nil, + productionYear: Int32? = nil, + indexNumber: Int32? = nil, + parentIndexNumber: Int32? = nil, + primaryImageTag: String? = nil, + thumbImageTag: String? = nil, + backdropImageTag: String? = nil, + isFolder: Bool? = nil, + runTimeTicks: Int64? = nil, + series: String? = nil + ) { + self.itemID = itemID + self.id = id + self.name = name + self.matchedTerm = matchedTerm + self.kind = kind + self.productionYear = productionYear + self.indexNumber = indexNumber + self.parentIndexNumber = parentIndexNumber + self.primaryImageTag = primaryImageTag + self.thumbImageTag = thumbImageTag + self.backdropImageTag = backdropImageTag + self.isFolder = isFolder + self.runTimeTicks = runTimeTicks + self.series = series + } +} diff --git a/Sources/LuminateCore/Models/JellyfinServerInfo.swift b/Sources/LuminateCore/Models/JellyfinServerInfo.swift new file mode 100644 index 0000000..fdad588 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinServerInfo.swift @@ -0,0 +1,63 @@ +// +// JellyfinServerInfo.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 +// + +/// Identifying details about a Jellyfin server. +/// +/// Serves both the pre-auth public endpoint and the authenticated one; the public response simply +/// leaves more fields `nil`. Fetching this is also how Luminate tests whether a URL points at a +/// reachable Jellyfin server. +package struct JellyfinServerInfo: Hashable, Sendable { + /// The server's stable identifier. + package var id: String? + /// The administrator-chosen server name. + package var serverName: String? + /// The server version, such as `10.11.10`. + package var version: String? + /// The product name the server reports, normally `Jellyfin Server`. + package var productName: String? + /// The operating system the server runs on. + package var operatingSystem: String? + /// The server's address on the local network. + package var localAddress: String? + /// Whether first-run setup has been completed; a server still in setup rejects normal traffic. + package var startupWizardCompleted: Bool? + + /// Creates server information, defaulting every unspecified field to `nil`. + /// + /// Each parameter sets the property of the same name. + package init( + id: String? = nil, + serverName: String? = nil, + version: String? = nil, + productName: String? = nil, + operatingSystem: String? = nil, + localAddress: String? = nil, + startupWizardCompleted: Bool? = nil + ) { + self.id = id + self.serverName = serverName + self.version = version + self.productName = productName + self.operatingSystem = operatingSystem + self.localAddress = localAddress + self.startupWizardCompleted = startupWizardCompleted + } +} diff --git a/Sources/LuminateCore/Models/JellyfinSortField.swift b/Sources/LuminateCore/Models/JellyfinSortField.swift new file mode 100644 index 0000000..36c516d --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinSortField.swift @@ -0,0 +1,70 @@ +// +// JellyfinSortField.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 +// + +/// The fields a Jellyfin server can sort a result set by. +/// +/// Raw values are the server's `ItemSortBy` spellings. Several fields only make sense for a +/// particular media kind: `airedEpisodeOrder` and `seriesSortName` apply to episodes, and +/// `dateLastContentAdded` applies to series and libraries. +package enum JellyfinSortField: String, CaseIterable, Hashable, Sendable { + /// The server's own default ordering for the queried collection. + /// + /// Named `defaultOrder` rather than `default` because `default` is a Swift keyword. + case defaultOrder = "Default" + /// Broadcast order, for episodes. + case airedEpisodeOrder = "AiredEpisodeOrder" + /// When the item was added to the library. + case dateCreated = "DateCreated" + /// The parental rating certificate. + case officialRating = "OfficialRating" + /// When the current user last played the item. + case datePlayed = "DatePlayed" + /// The original release date. + case premiereDate = "PremiereDate" + /// The server-computed sort title. + case sortName = "SortName" + /// The display title. + case name = "Name" + /// A shuffled ordering, reshuffled per request. + case random = "Random" + /// Total duration. + case runtime = "Runtime" + /// The aggregate community score. + case communityRating = "CommunityRating" + /// The year of production. + case productionYear = "ProductionYear" + /// How many times the current user played the item. + case playCount = "PlayCount" + /// The aggregate critic score. + case criticRating = "CriticRating" + /// Played items first. + case isPlayed = "IsPlayed" + /// Unplayed items first. + case isUnplayed = "IsUnplayed" + /// The parent series' sort title, for episodes. + case seriesSortName = "SeriesSortName" + /// When content was most recently added beneath the item. + case dateLastContentAdded = "DateLastContentAdded" + /// The season number, for episodes. + case parentIndexNumber = "ParentIndexNumber" + /// The episode number within its season. + case indexNumber = "IndexNumber" +} diff --git a/Sources/LuminateCore/Models/JellyfinSortOrder.swift b/Sources/LuminateCore/Models/JellyfinSortOrder.swift new file mode 100644 index 0000000..47ed7cd --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinSortOrder.swift @@ -0,0 +1,31 @@ +// +// JellyfinSortOrder.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 +// + +/// The direction a ``JellyfinSortField`` is applied in. +/// +/// A query may carry one order per sort field; a shorter order list than field list leaves the +/// remaining fields ascending. +package enum JellyfinSortOrder: String, CaseIterable, Hashable, Sendable { + /// Smallest, earliest, or alphabetically first result comes first. + case ascending = "Ascending" + /// Largest, latest, or alphabetically last result comes first. + case descending = "Descending" +} diff --git a/Sources/LuminateCore/Models/JellyfinUser.swift b/Sources/LuminateCore/Models/JellyfinUser.swift new file mode 100644 index 0000000..8c9cb19 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinUser.swift @@ -0,0 +1,73 @@ +// +// JellyfinUser.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 + +/// A Jellyfin user account as Luminate needs it for the login and profile flows. +/// +/// The server returns a much wider `UserDto`, most of which is administrative policy Luminate does +/// not act on. Every property is optional because the public user list a server exposes before +/// login is deliberately sparse. +package struct JellyfinUser: Hashable, Sendable { + /// The server-assigned user identifier. + package var id: String? + /// The display name shown on the user picker. + package var name: String? + /// The identifier of the server this account belongs to. + package var serverID: String? + /// The image tag used to fetch the user's avatar, if one is set. + package var primaryImageTag: String? + /// Whether the account requires a password. + package var hasPassword: Bool? + /// Whether the account has a password configured. + package var hasConfiguredPassword: Bool? + /// Whether the account has a numeric Easy PIN configured. + package var hasConfiguredEasyPassword: Bool? + /// When the account last signed in. + package var lastLoginDate: Date? + /// The aspect ratio of the avatar image. + package var primaryImageAspectRatio: Double? + + /// Creates a user, defaulting every unspecified field to `nil`. + /// + /// Each parameter sets the property of the same name. + package init( + id: String? = nil, + name: String? = nil, + serverID: String? = nil, + primaryImageTag: String? = nil, + hasPassword: Bool? = nil, + hasConfiguredPassword: Bool? = nil, + hasConfiguredEasyPassword: Bool? = nil, + lastLoginDate: Date? = nil, + primaryImageAspectRatio: Double? = nil + ) { + self.id = id + self.name = name + self.serverID = serverID + self.primaryImageTag = primaryImageTag + self.hasPassword = hasPassword + self.hasConfiguredPassword = hasConfiguredPassword + self.hasConfiguredEasyPassword = hasConfiguredEasyPassword + self.lastLoginDate = lastLoginDate + self.primaryImageAspectRatio = primaryImageAspectRatio + } +} diff --git a/Sources/LuminateCore/Models/JellyfinUserData.swift b/Sources/LuminateCore/Models/JellyfinUserData.swift new file mode 100644 index 0000000..522998a --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinUserData.swift @@ -0,0 +1,80 @@ +// +// JellyfinUserData.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 + +/// One user's playback and favourite state for a single item. +/// +/// Attached to a ``JellyfinMediaItem`` when the request enabled user data, and returned on its own +/// by the played and favourite mutations so a caller can refresh a row without refetching the item. +package struct JellyfinUserData: Hashable, Sendable { + /// The item this state belongs to. + package var itemID: String? + /// The server-side key that groups state for repeated items. + package var key: String? + /// The user's personal rating. + package var rating: Double? + /// How far through the item the user is, as a percentage. + package var playedPercentage: Double? + /// How many descendants of a folder or season remain unplayed. + package var unplayedItemCount: Int32? + /// The resume position, in 100-nanosecond ticks. + package var playbackPositionTicks: Int64? + /// How many times the user has played the item. + package var playCount: Int32? + /// Whether the user marked the item as a favourite. + package var isFavorite: Bool? + /// Whether the user gave the item a thumbs up or down. + package var likes: Bool? + /// When the user last played the item. + package var lastPlayedDate: Date? + /// Whether the item counts as watched. + package var played: Bool? + + /// Creates user data, defaulting every unspecified field to `nil`. + /// + /// Each parameter sets the property of the same name. + package init( + itemID: String? = nil, + key: String? = nil, + rating: Double? = nil, + playedPercentage: Double? = nil, + unplayedItemCount: Int32? = nil, + playbackPositionTicks: Int64? = nil, + playCount: Int32? = nil, + isFavorite: Bool? = nil, + likes: Bool? = nil, + lastPlayedDate: Date? = nil, + played: Bool? = nil + ) { + self.itemID = itemID + self.key = key + self.rating = rating + self.playedPercentage = playedPercentage + self.unplayedItemCount = unplayedItemCount + self.playbackPositionTicks = playbackPositionTicks + self.playCount = playCount + self.isFavorite = isFavorite + self.likes = likes + self.lastPlayedDate = lastPlayedDate + self.played = played + } +} diff --git a/Sources/LuminateCore/Protocols/JellyfinService+Convenience.swift b/Sources/LuminateCore/Protocols/JellyfinService+Convenience.swift new file mode 100644 index 0000000..360a805 --- /dev/null +++ b/Sources/LuminateCore/Protocols/JellyfinService+Convenience.swift @@ -0,0 +1,203 @@ +// +// JellyfinService+Convenience.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 + +/// Shorter spellings of the ``JellyfinService`` requirements for the common cases. +/// +/// A protocol requirement cannot declare default arguments, so the defaults live here instead as +/// reduced-arity overloads. Each one forwards to the full-arity requirement; because the arities +/// differ, the forward resolves to the requirement rather than recursing. +extension JellyfinService { + /// Fetches the signed-in user's libraries. + /// + /// - Returns: The libraries, in server order. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func libraries() async throws -> [JellyfinLibrary] { + try await libraries(JellyfinListOptions()) + } + + /// Fetches the first page of items with no filters applied. + /// + /// - Returns: One page of items plus the total match count. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func items() async throws -> JellyfinMediaPage { + try await items(JellyfinMediaQuery()) + } + + /// Fetches one item in full detail for the signed-in user. + /// + /// - Parameter id: The item identifier. Must not be empty. + /// - Returns: The item. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `id` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + package func item(id: String) async throws -> JellyfinMediaItem { + try await item(id: id, userID: nil) + } + + /// Fetches the signed-in user's Continue Watching row. + /// + /// - Returns: One page of partially played items. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func resumeItems() async throws -> JellyfinMediaPage { + try await resumeItems(JellyfinListOptions()) + } + + /// Fetches the signed-in user's Next Up row across every series. + /// + /// - Returns: One page of next-up episodes. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func nextUp() async throws -> JellyfinMediaPage { + try await nextUp(seriesID: nil, options: JellyfinListOptions()) + } + + /// Fetches the Next Up row across every series with explicit options. + /// + /// - Parameter options: Honours `userID`, `parentID`, `startIndex`, `limit`, and `fields`. + /// - Returns: One page of next-up episodes. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func nextUp(options: JellyfinListOptions) async throws -> JellyfinMediaPage { + try await nextUp(seriesID: nil, options: options) + } + + /// Fetches the signed-in user's Latest Media row. + /// + /// - Returns: The most recently added items, newest first. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func latestMedia() async throws -> [JellyfinMediaItem] { + try await latestMedia(JellyfinListOptions()) + } + + /// Fetches the seasons of a series for the signed-in user. + /// + /// - Parameter seriesID: The series identifier. Must not be empty. + /// - Returns: The seasons, in broadcast order. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `seriesID` is empty, or + /// ``JellyfinClientError/notFound`` if no such series exists. + package func seasons(seriesID: String) async throws -> JellyfinMediaPage { + try await seasons(seriesID: seriesID, options: JellyfinListOptions()) + } + + /// Fetches every episode of a series for the signed-in user. + /// + /// - Parameter seriesID: The series identifier. Must not be empty. + /// - Returns: One page of episodes, in broadcast order. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `seriesID` is empty, or + /// ``JellyfinClientError/notFound`` if no such series exists. + package func episodes(seriesID: String) async throws -> JellyfinMediaPage { + try await episodes(seriesID: seriesID, options: JellyfinListOptions()) + } + + /// Runs a search-as-you-type query with the server's default result limit. + /// + /// - Parameter term: The search text. Must not be empty or whitespace only. + /// - Returns: The matching hints, best match first. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `term` is blank. + package func searchHints(term: String) async throws -> [JellyfinSearchHint] { + try await searchHints(term: term, options: JellyfinListOptions()) + } + + /// Downloads an item's first image of a given type at the server's default rendition. + /// + /// - Parameters: + /// - itemID: The item that owns the artwork. Must not be empty. + /// - type: Which artwork slot to fetch. + /// - Returns: The encoded image bytes. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if the item has no image in that slot. + package func image(itemID: String, type: JellyfinImageType) async throws -> Data { + try await image(itemID: itemID, type: type, index: nil, request: JellyfinImageRequest()) + } + + /// Downloads an item's first image of a given type at a specific rendition. + /// + /// - Parameters: + /// - itemID: The item that owns the artwork. Must not be empty. + /// - type: Which artwork slot to fetch. + /// - request: The rendition to ask the server for. + /// - Returns: The encoded image bytes. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if the item has no image in that slot. + package func image( + itemID: String, + type: JellyfinImageType, + request: JellyfinImageRequest + ) async throws -> Data { + try await image(itemID: itemID, type: type, index: nil, request: request) + } + + /// Marks an item watched by the signed-in user, as of now. + /// + /// - Parameter itemID: The item to mark. Must not be empty. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + package func markPlayed(itemID: String) async throws -> JellyfinUserData { + try await markPlayed(itemID: itemID, userID: nil, datePlayed: nil) + } + + /// Marks an item unwatched by the signed-in user. + /// + /// - Parameter itemID: The item to mark. Must not be empty. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + package func markUnplayed(itemID: String) async throws -> JellyfinUserData { + try await markUnplayed(itemID: itemID, userID: nil) + } + + /// Adds an item to the signed-in user's favourites. + /// + /// - Parameter itemID: The item to favourite. Must not be empty. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + package func markFavorite(itemID: String) async throws -> JellyfinUserData { + try await markFavorite(itemID: itemID, userID: nil) + } + + /// Removes an item from the signed-in user's favourites. + /// + /// - Parameter itemID: The item to unfavourite. Must not be empty. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + package func unmarkFavorite(itemID: String) async throws -> JellyfinUserData { + try await unmarkFavorite(itemID: itemID, userID: nil) + } + + /// Changes the signed-in account's password. + /// + /// - Parameters: + /// - currentPassword: The existing password. + /// - newPassword: The replacement password. + /// - Throws: ``JellyfinClientError/unauthorized`` if the current password is wrong, or + /// ``JellyfinClientError/forbidden`` if the account may not change its own password. + package func updateUserPassword(currentPassword: String?, newPassword: String?) async throws { + try await updateUserPassword( + userID: nil, + currentPassword: currentPassword, + currentPIN: nil, + newPassword: newPassword, + resetPassword: nil + ) + } +} diff --git a/Sources/LuminateCore/Protocols/JellyfinService.swift b/Sources/LuminateCore/Protocols/JellyfinService.swift new file mode 100644 index 0000000..f7b4441 --- /dev/null +++ b/Sources/LuminateCore/Protocols/JellyfinService.swift @@ -0,0 +1,291 @@ +// +// JellyfinService.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 + +/// The Jellyfin operations Luminate's feature code is allowed to call. +/// +/// The concrete implementation lives in `LuminateServices` and owns the generated OpenAPI client. +/// Feature and UI targets depend on this protocol instead, which is what keeps generated +/// `Operations.*` and `Components.*` types from leaking above the service layer and lets a +/// regenerated spec stay a service-layer concern. +/// +/// Views reach an implementation through the environment: +/// +/// ```swift +/// @Environment(\.client) private var jellyfinClient +/// ``` +/// +/// Requirements take every argument explicitly because a protocol requirement cannot declare +/// default arguments. The reduced-arity conveniences in `JellyfinService+Convenience.swift` cover +/// the common cases, so `try await client.libraries()` still reads well. +/// +/// Configuration -- the server URL and the access token -- is deliberately absent. That is +/// construction-time wiring owned by whoever builds the client, not something a view should reach +/// through the environment and mutate. ``authenticate(username:password:)`` and +/// ``authenticateWithQuickConnect(secret:)`` store the token they receive, so a signed-in +/// implementation stays usable without any further setup. +package protocol JellyfinService: AnyObject, Sendable { + /// Whether the service currently holds an access token. + /// + /// This reports only that a token is present, not that the server still accepts it; an expired + /// token surfaces as ``JellyfinClientError/unauthorized`` on the next call. + var isAuthenticated: Bool { get async } + + /// Fetches the identifying details a server exposes without authentication. + /// + /// This is Luminate's connection test: it is the cheapest call that proves a URL points at a + /// reachable Jellyfin server. + /// + /// - Returns: The server's public information. + /// - Throws: ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is + /// still starting, or a transport error if the host is unreachable. + func publicServerInfo() async throws -> JellyfinServerInfo + + /// Fetches the full identifying details of the signed-in server. + /// + /// - Returns: The server's information. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + func serverInfo() async throws -> JellyfinServerInfo + + /// Fetches the accounts the server advertises on its login screen. + /// + /// Servers configured to hide their user list return an empty array rather than failing. + /// + /// - Returns: The publicly visible accounts. + /// - Throws: A transport error if the server is unreachable. + func publicUsers() async throws -> [JellyfinUser] + + /// Signs in with a username and password and stores the resulting token on the service. + /// + /// Every later call made through this service is authenticated as the returned account. + /// + /// - Parameters: + /// - username: The account name. Must not be empty. + /// - password: The account password. May be empty; Jellyfin permits passwordless accounts. + /// - Returns: The issued token and the account it belongs to. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `username` is empty, + /// ``JellyfinClientError/unauthorized`` if the credentials are rejected, or + /// ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the token. + func authenticate(username: String, password: String) async throws -> JellyfinAuthentication + + /// Reports whether the server allows Quick Connect pairing. + /// + /// - Returns: `true` when Quick Connect is enabled. + /// - Throws: A transport error if the server is unreachable. + func quickConnectEnabled() async throws -> Bool + + /// Starts a Quick Connect pairing attempt. + /// + /// Show the returned ``JellyfinQuickConnectState/code`` to the user, keep the + /// ``JellyfinQuickConnectState/secret`` private, and poll ``quickConnectState(secret:)`` until + /// it reports the request as authenticated. + /// + /// - Returns: The new pairing request. + /// - Throws: ``JellyfinClientError/unauthorized`` if the server has Quick Connect disabled. + func initiateQuickConnect() async throws -> JellyfinQuickConnectState + + /// Polls an in-flight Quick Connect request. + /// + /// - Parameter secret: The secret from ``initiateQuickConnect()``. Must not be empty. + /// - Returns: The current state of the request. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `secret` is empty, or + /// ``JellyfinClientError/notFound`` once the server has expired the request. + func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState + + /// Redeems an approved Quick Connect secret and stores the resulting token on the service. + /// + /// - Parameter secret: The secret from ``initiateQuickConnect()``. Must not be empty. + /// - Returns: The issued token and the account it belongs to. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `secret` is empty, + /// ``JellyfinClientError/unauthorized`` if the request was never approved, or + /// ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the token. + func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication + + /// Changes or resets an account password. + /// + /// Also serves the server-mandated forced password change: pass the current password alongside + /// the new one. + /// + /// - Parameters: + /// - userID: The account to change; `nil` means the signed-in account. + /// - currentPassword: The existing password, required unless resetting. + /// - currentPIN: The existing numeric Easy PIN, when the account uses one. + /// - newPassword: The replacement password. + /// - resetPassword: Pass `true` to clear the password instead of setting a new one. + /// - Throws: ``JellyfinClientError/unauthorized`` if the current password is wrong, or + /// ``JellyfinClientError/forbidden`` if the account may not change its own password. + func updateUserPassword( + userID: String?, + currentPassword: String?, + currentPIN: String?, + newPassword: String?, + resetPassword: Bool? + ) async throws + + /// Fetches the libraries a user can browse. + /// + /// - Parameter options: Honours ``JellyfinListOptions/userID`` and + /// ``JellyfinListOptions/includeHidden``. + /// - Returns: The user's libraries, in server order. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + func libraries(_ options: JellyfinListOptions) async throws -> [JellyfinLibrary] + + /// Runs a browse, filter, or search query. + /// + /// - Parameter query: The filters, sorting, and paging to apply. + /// - Returns: One page of matching items plus the total match count. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + func items(_ query: JellyfinMediaQuery) async throws -> JellyfinMediaPage + + /// Fetches one item in full detail. + /// + /// - Parameters: + /// - id: The item identifier. Must not be empty. + /// - userID: Whose playback state to include; `nil` means the signed-in account. + /// - Returns: The item. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `id` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + func item(id: String, userID: String?) async throws -> JellyfinMediaItem + + /// Fetches the Continue Watching row: items the user started but did not finish. + /// + /// Results are restricted to movies and episodes unless + /// ``JellyfinListOptions/includeItemKinds`` says otherwise, so out-of-scope media never appears. + /// + /// - Parameter options: Honours `userID`, `parentID`, `startIndex`, `limit`, `fields`, and + /// `includeItemKinds`. + /// - Returns: One page of partially played items. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + func resumeItems(_ options: JellyfinListOptions) async throws -> JellyfinMediaPage + + /// Fetches the Next Up row: the next unwatched episode of each in-progress series. + /// + /// - Parameters: + /// - seriesID: Restrict to one series; `nil` covers every series the user is watching. + /// - options: Honours `userID`, `parentID`, `startIndex`, `limit`, and `fields`. + /// - Returns: One page of next-up episodes. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + func nextUp(seriesID: String?, options: JellyfinListOptions) async throws -> JellyfinMediaPage + + /// Fetches the Latest Media row: the most recently added items. + /// + /// This endpoint returns a plain list rather than a page, so it carries no total count. + /// + /// - Parameter options: Honours `userID`, `parentID`, `limit`, `fields`, and `groupItems`. + /// - Returns: The most recently added items, newest first. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + func latestMedia(_ options: JellyfinListOptions) async throws -> [JellyfinMediaItem] + + /// Fetches the seasons of a series. + /// + /// - Parameters: + /// - seriesID: The series identifier. Must not be empty. + /// - options: Honours `userID` and `fields`. + /// - Returns: The seasons, in broadcast order. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `seriesID` is empty, or + /// ``JellyfinClientError/notFound`` if no such series exists. + func seasons(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage + + /// Fetches the episodes of a series, optionally narrowed to one season. + /// + /// - Parameters: + /// - seriesID: The series identifier. Must not be empty. + /// - options: Honours `userID`, `seasonID`, `season`, `startIndex`, `limit`, and `fields`. + /// - Returns: One page of episodes, in broadcast order. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `seriesID` is empty, or + /// ``JellyfinClientError/notFound`` if no such series exists. + func episodes(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage + + /// Runs a search-as-you-type query. + /// + /// Only media hints are requested; people, genres, and studios are excluded because Luminate + /// searches movies and TV only. + /// + /// - Parameters: + /// - term: The search text. Must not be empty or whitespace only. + /// - options: Honours `userID`, `limit`, and `includeItemKinds`. + /// - Returns: The matching hints, best match first. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `term` is blank. + func searchHints(term: String, options: JellyfinListOptions) async throws -> [JellyfinSearchHint] + + /// Downloads one artwork image, fully buffered. + /// + /// The response is collected into memory with a 25 MiB ceiling, which no poster or backdrop + /// approaches; a larger body raises the runtime's own oversize error rather than a + /// ``JellyfinClientError``. + /// + /// - 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 + /// ``JellyfinClientError/notFound`` if the item has no image in that slot. + func image( + itemID: String, + type: JellyfinImageType, + index: Int32?, + request: JellyfinImageRequest + ) async throws -> Data + + /// Marks an item watched. + /// + /// - Parameters: + /// - itemID: The item to mark. Must not be empty. + /// - userID: Whose state to change; `nil` means the signed-in account. + /// - datePlayed: When it was watched; `nil` means now. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + func markPlayed(itemID: String, userID: String?, datePlayed: Date?) async throws -> JellyfinUserData + + /// Marks an item unwatched and clears its resume position. + /// + /// - Parameters: + /// - itemID: The item to mark. Must not be empty. + /// - userID: Whose state to change; `nil` means the signed-in account. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + func markUnplayed(itemID: String, userID: String?) async throws -> JellyfinUserData + + /// Adds an item to the user's favourites. + /// + /// - Parameters: + /// - itemID: The item to favourite. Must not be empty. + /// - userID: Whose favourites to change; `nil` means the signed-in account. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + func markFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData + + /// Removes an item from the user's favourites. + /// + /// - Parameters: + /// - itemID: The item to unfavourite. Must not be empty. + /// - userID: Whose favourites to change; `nil` means the signed-in account. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + func unmarkFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData +} diff --git a/Sources/LuminateCore/Protocols/UnconfiguredJellyfinService.swift b/Sources/LuminateCore/Protocols/UnconfiguredJellyfinService.swift new file mode 100644 index 0000000..bb4e325 --- /dev/null +++ b/Sources/LuminateCore/Protocols/UnconfiguredJellyfinService.swift @@ -0,0 +1,166 @@ +// +// UnconfiguredJellyfinService.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 + +/// A ``JellyfinService`` that has no server and fails every call. +/// +/// This fills the `\.client` environment slot when nothing has been injected. Every operation +/// throws ``JellyfinClientError/notConfigured``, so a view mounted without +/// `.environment(\.client, ...)` reports a precise, actionable failure at its first call rather +/// than silently talking to a placeholder host or trapping at mount time. +/// +/// It is stateless, so the single ``shared`` instance serves every unresolved slot. +package final class UnconfiguredJellyfinService: JellyfinService { + /// The shared placeholder instance. + package static let shared = UnconfiguredJellyfinService() + + private init() {} + + /// Always `false`: a service with no server can hold no token. + package var isAuthenticated: Bool { false } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func publicServerInfo() async throws -> JellyfinServerInfo { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func serverInfo() async throws -> JellyfinServerInfo { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func publicUsers() async throws -> [JellyfinUser] { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func authenticate(username: String, password: String) async throws -> JellyfinAuthentication { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func quickConnectEnabled() async throws -> Bool { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func initiateQuickConnect() async throws -> JellyfinQuickConnectState { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func updateUserPassword( + userID: String?, + currentPassword: String?, + currentPIN: String?, + newPassword: String?, + resetPassword: Bool? + ) async throws { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func libraries(_ options: JellyfinListOptions) async throws -> [JellyfinLibrary] { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func items(_ query: JellyfinMediaQuery) async throws -> JellyfinMediaPage { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func item(id: String, userID: String?) async throws -> JellyfinMediaItem { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func resumeItems(_ options: JellyfinListOptions) async throws -> JellyfinMediaPage { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func nextUp(seriesID: String?, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func latestMedia(_ options: JellyfinListOptions) async throws -> [JellyfinMediaItem] { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func seasons(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func episodes(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func searchHints(term: String, options: JellyfinListOptions) async throws -> [JellyfinSearchHint] { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func image( + itemID: String, + type: JellyfinImageType, + index: Int32?, + request: JellyfinImageRequest + ) async throws -> Data { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func markPlayed(itemID: String, userID: String?, datePlayed: Date?) async throws -> JellyfinUserData { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func markUnplayed(itemID: String, userID: String?) async throws -> JellyfinUserData { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func markFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData { + throw JellyfinClientError.notConfigured + } + + /// - Throws: Always ``JellyfinClientError/notConfigured``. + package func unmarkFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData { + throw JellyfinClientError.notConfigured + } +} diff --git a/Sources/LuminateServices/Auth/AuthenticationMiddleware.swift b/Sources/LuminateServices/Auth/AuthenticationMiddleware.swift new file mode 100644 index 0000000..b42afd4 --- /dev/null +++ b/Sources/LuminateServices/Auth/AuthenticationMiddleware.swift @@ -0,0 +1,97 @@ +// +// AuthenticationMiddleware.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 + +/// Stamps Jellyfin's `Authorization` header onto every outgoing request. +/// +/// Jellyfin does not use a bare bearer token. It expects a `MediaBrowser` scheme carrying the +/// client identity alongside the token: +/// +/// ``` +/// Authorization: MediaBrowser Client="Luminate", Device="workstation", +/// DeviceId="...", Version="0.1.0", Token="..." +/// ``` +/// +/// The server keys a session by `DeviceId`, so that value must stay stable across launches. Before +/// sign-in the token is sent as an empty string, which is what the pre-auth endpoints expect. +/// +/// This middleware is the only place that header is built; no call site assembles it by hand. +package struct AuthenticationMiddleware: ClientMiddleware { + /// The application name reported to the server. + package let clientName: String + /// The human-readable device name shown in the server's active-devices list. + package let deviceName: String + /// The stable per-machine device identifier the server keys its session by. + package let deviceID: String + /// The application version reported to the server. + package let version: String + /// The access token, or `nil` before sign-in. + package let token: String? + + /// Creates a middleware for one client identity. + /// + /// - Parameters: + /// - clientName: The application name reported to the server. + /// - deviceName: The human-readable device name. + /// - deviceID: The stable per-machine device identifier. + /// - version: The application version. + /// - token: The access token, or `nil` before sign-in. + package init(clientName: String, deviceName: String, deviceID: String, version: String, token: String?) { + self.clientName = clientName + self.deviceName = deviceName + self.deviceID = deviceID + self.version = version + self.token = token + } + + /// Adds the `Authorization` header and forwards the request down the chain. + /// + /// - Parameters: + /// - request: The request as assembled by the generated client. + /// - body: The request body, if any. + /// - baseURL: The server URL the request is bound for. + /// - operationID: The Jellyfin operation identifier, unused here. + /// - next: The next step in the middleware chain. + /// - Returns: Whatever `next` returns, unmodified. + /// - Throws: Whatever `next` throws. + package func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + var request = request + request.headerFields[.authorization] = authorizationValue + return try await next(request, body, baseURL) + } + + /// The `MediaBrowser` credential string, with an empty token when unauthenticated. + private var authorizationValue: String { + """ + MediaBrowser Client="\(clientName)", Device="\(deviceName)", DeviceId="\(deviceID)", \ + Version="\(version)", Token="\(token ?? "")" + """ + } +} diff --git a/Sources/LuminateServices/Auth/JellyfinClient+Auth.swift b/Sources/LuminateServices/Auth/JellyfinClient+Auth.swift new file mode 100644 index 0000000..f214d8c --- /dev/null +++ b/Sources/LuminateServices/Auth/JellyfinClient+Auth.swift @@ -0,0 +1,273 @@ +// +// JellyfinClient+Auth.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 LuminateAPI +import LuminateCore + +/// Sign-in, Quick Connect pairing, and password management. +extension JellyfinClient { + /// Fetches the accounts the server advertises on its login screen. + /// + /// A server configured to hide its user list answers with an empty array rather than failing, + /// so an empty result means "type your username", not "no accounts exist". + /// + /// - Returns: The publicly visible accounts. + /// - Throws: ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is + /// starting, or a transport error if the host is unreachable. + package func publicUsers() async throws -> [JellyfinUser] { + switch try await current.getPublicUsers(.init()) { + case .ok(let response): + return response.body.payload.map(JellyfinUser.init) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetPublicUsers.id, + statusCode: statusCode + ) + } + } + + /// Signs in with a username and password and stores the resulting token on this client. + /// + /// Every later call through this client is authenticated as the returned account, so a caller + /// does not have to plumb the token back in. + /// + /// Jellyfin answers bad credentials with an undocumented 401, which surfaces as + /// ``JellyfinClientError/unexpectedStatus(operation:statusCode:)`` rather than + /// ``JellyfinClientError/unauthorized``: the specification declares no 401 for this operation. + /// + /// - Parameters: + /// - username: The account name. Must not be empty. + /// - password: The account password. May be empty; Jellyfin permits passwordless accounts. + /// - Returns: The issued token and the account it belongs to. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `username` is empty, or + /// ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the token. + package func authenticate(username: String, password: String) async throws -> JellyfinAuthentication { + guard !username.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "username") + } + let input = Operations.AuthenticateUserByName.Input( + body: .json(.init(value1: Components.Schemas.AuthenticateUserByName(username: username, pw: password))) + ) + switch try await current.authenticateUserByName(input) { + case .ok(let response): + return try await store(response.body.payload, from: Operations.AuthenticateUserByName.id) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.AuthenticateUserByName.id, + statusCode: statusCode + ) + } + } + + /// Reports whether the server allows Quick Connect pairing. + /// + /// - Returns: `true` when Quick Connect is enabled. + /// - Throws: ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is + /// starting, or a transport error if the host is unreachable. + package func quickConnectEnabled() async throws -> Bool { + switch try await current.getQuickConnectEnabled(.init()) { + case .ok(let response): + return response.body.payload + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetQuickConnectEnabled.id, + statusCode: statusCode + ) + } + } + + /// Starts a Quick Connect pairing attempt. + /// + /// Show the returned ``JellyfinQuickConnectState/code`` to the user, keep the + /// ``JellyfinQuickConnectState/secret`` private, and poll ``quickConnectState(secret:)`` until + /// it reports the request authenticated. + /// + /// - Returns: The new pairing request. + /// - Throws: ``JellyfinClientError/unauthorized`` if the server has Quick Connect disabled. + package func initiateQuickConnect() async throws -> JellyfinQuickConnectState { + switch try await current.initiateQuickConnect(.init()) { + case .ok(let response): + return JellyfinQuickConnectState(response.body.payload) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.InitiateQuickConnect.id, + statusCode: statusCode + ) + } + } + + /// Polls an in-flight Quick Connect request. + /// + /// - Parameter secret: The secret from ``initiateQuickConnect()``. Must not be empty. + /// - Returns: The current state of the request. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `secret` is empty, or + /// ``JellyfinClientError/notFound`` once the server has expired the request. + package func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState { + guard !secret.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "secret") + } + switch try await current.getQuickConnectState(.init(query: .init(secret: secret))) { + case .ok(let response): + return JellyfinQuickConnectState(response.body.payload) + 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.GetQuickConnectState.id, + statusCode: statusCode + ) + } + } + + /// Redeems an approved Quick Connect secret and stores the resulting token on this client. + /// + /// - Parameter secret: The secret from ``initiateQuickConnect()``. Must not be empty. + /// - Returns: The issued token and the account it belongs to. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `secret` is empty, + /// ``JellyfinClientError/badRequest`` if the request was never approved, or + /// ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the token. + package func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication { + guard !secret.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "secret") + } + let input = Operations.AuthenticateWithQuickConnect.Input( + body: .json(.init(value1: Components.Schemas.QuickConnectDto(secret: secret))) + ) + switch try await current.authenticateWithQuickConnect(input) { + case .ok(let response): + return try await store(response.body.payload, from: Operations.AuthenticateWithQuickConnect.id) + case .badRequest: + throw JellyfinClientError.badRequest + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.AuthenticateWithQuickConnect.id, + statusCode: statusCode + ) + } + } + + /// Changes or resets an account password. + /// + /// Also serves the server-mandated forced password change: pass the current password alongside + /// the new one. + /// + /// - Parameters: + /// - userID: The account to change; `nil` means the signed-in account. + /// - currentPassword: The existing password, required unless resetting. + /// - currentPIN: The existing numeric Easy PIN, when the account uses one. + /// - newPassword: The replacement password. + /// - resetPassword: Pass `true` to clear the password instead of setting a new one. + /// - Throws: ``JellyfinClientError/unauthorized`` if the current password is wrong, + /// ``JellyfinClientError/forbidden`` if the account may not change its own password, or + /// ``JellyfinClientError/notFound`` if no such account exists. + package func updateUserPassword( + userID: String?, + currentPassword: String?, + currentPIN: String?, + newPassword: String?, + resetPassword: Bool? + ) async throws { + let input = Operations.UpdateUserPassword.Input( + query: .init(userId: userID), + body: .json( + .init( + value1: Components.Schemas.UpdateUserPassword( + currentPassword: currentPassword, + currentPw: currentPIN, + newPw: newPassword, + resetPassword: resetPassword + ) + ) + ) + ) + switch try await current.updateUserPassword(input) { + case .noContent: + return + case .forbidden: + throw JellyfinClientError.forbidden + case .notFound: + throw JellyfinClientError.notFound + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.UpdateUserPassword.id, + statusCode: statusCode + ) + } + } + + /// Maps an authentication result and adopts its token as this client's credentials. + /// + /// Shared by password and Quick Connect sign-in, which differ only in how they obtain the + /// result. A result with no token is treated as a failure: the server said yes but gave the + /// client nothing to authenticate with. + /// + /// - Parameters: + /// - result: The generated authentication result. + /// - operation: The operation identifier, used in the thrown error. + /// - Returns: The mapped authentication. + /// - Throws: ``JellyfinClientError/missingPayload(operation:)`` if the token is absent or empty. + private func store( + _ result: Components.Schemas.AuthenticationResult, + from operation: String + ) async throws -> JellyfinAuthentication { + guard let token = result.accessToken, !token.isEmpty else { + throw JellyfinClientError.missingPayload(operation: operation) + } + setAccessToken(token) + return JellyfinAuthentication( + accessToken: token, + serverID: result.serverId, + user: result.user.map { JellyfinUser($0.value1) } + ) + } +} diff --git a/Sources/LuminateServices/Discovery/JellyfinClient+System.swift b/Sources/LuminateServices/Discovery/JellyfinClient+System.swift new file mode 100644 index 0000000..237c915 --- /dev/null +++ b/Sources/LuminateServices/Discovery/JellyfinClient+System.swift @@ -0,0 +1,77 @@ +// +// JellyfinClient+System.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 LuminateAPI +import LuminateCore + +/// Server discovery and identification. +extension JellyfinClient { + /// Fetches the identifying details a server exposes without authentication. + /// + /// This doubles as Luminate's connection test: it is the cheapest call that proves a URL points + /// at a reachable Jellyfin server, and it needs no token. + /// + /// - Returns: The server's public information. + /// - Throws: ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is + /// starting, ``JellyfinClientError/unexpectedStatus(operation:statusCode:)`` for any other + /// status, or a transport error if the host is unreachable. + package func publicServerInfo() async throws -> JellyfinServerInfo { + switch try await current.getPublicSystemInfo(.init()) { + case .ok(let response): + return JellyfinServerInfo(response.body.payload) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetPublicSystemInfo.id, + statusCode: statusCode + ) + } + } + + /// Fetches the full identifying details of the signed-in server. + /// + /// - Returns: The server's information. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected, + /// ``JellyfinClientError/forbidden`` if the account may not read it, or + /// ``JellyfinClientError/serviceUnavailable(retryAfterSeconds:)`` while the server is starting. + package func serverInfo() async throws -> JellyfinServerInfo { + switch try await current.getSystemInfo(.init()) { + case .ok(let response): + return JellyfinServerInfo(response.body.payload) + case .forbidden: + throw JellyfinClientError.forbidden + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetSystemInfo.id, + statusCode: statusCode + ) + } + } +} diff --git a/Sources/LuminateServices/Library/JellyfinClient+Images.swift b/Sources/LuminateServices/Library/JellyfinClient+Images.swift new file mode 100644 index 0000000..00fd819 --- /dev/null +++ b/Sources/LuminateServices/Library/JellyfinClient+Images.swift @@ -0,0 +1,144 @@ +// +// JellyfinClient+Images.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 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 } +} diff --git a/Sources/LuminateServices/Library/JellyfinClient+Library.swift b/Sources/LuminateServices/Library/JellyfinClient+Library.swift new file mode 100644 index 0000000..e6d7ca4 --- /dev/null +++ b/Sources/LuminateServices/Library/JellyfinClient+Library.swift @@ -0,0 +1,363 @@ +// +// JellyfinClient+Library.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 LuminateAPI +import LuminateCore + +/// Library listing, browsing, and the home-screen rows. +extension JellyfinClient { + /// Fetches the libraries a user can browse. + /// + /// - Parameter options: Honours ``JellyfinListOptions/userID`` and + /// ``JellyfinListOptions/includeHidden``. + /// - Returns: The user's libraries, in server order. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected, or + /// ``JellyfinClientError/forbidden`` if the account may not list them. + package func libraries(_ options: JellyfinListOptions) async throws -> [JellyfinLibrary] { + let input = Operations.GetUserViews.Input( + query: .init(userId: options.userID, includeHidden: options.includeHidden) + ) + switch try await current.getUserViews(input) { + case .ok(let response): + return (response.body.payload.items ?? []).map(JellyfinLibrary.init) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetUserViews.id, + statusCode: statusCode + ) + } + } + + /// Runs a browse, filter, or search query. + /// + /// - Parameter query: The filters, sorting, and paging to apply. + /// - Returns: One page of matching items plus the total match count. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected, or + /// ``JellyfinClientError/forbidden`` if the account may not read the queried scope. + package func items(_ query: JellyfinMediaQuery) async throws -> JellyfinMediaPage { + switch try await current.getItems(.init(query: .init(query))) { + case .ok(let response): + return JellyfinMediaPage(response.body.payload) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetItems.id, + statusCode: statusCode + ) + } + } + + /// Fetches one item in full detail. + /// + /// - Parameters: + /// - id: The item identifier. Must not be empty. + /// - userID: Whose playback state to include; `nil` means the signed-in account. + /// - Returns: The item. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `id` is empty, or + /// ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func item(id: String, userID: String?) async throws -> JellyfinMediaItem { + guard !id.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "id") + } + let input = Operations.GetItem.Input(path: .init(itemId: id), query: .init(userId: userID)) + switch try await current.getItem(input) { + case .ok(let response): + return JellyfinMediaItem(response.body.payload) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetItem.id, + statusCode: statusCode + ) + } + } + + /// Fetches the Continue Watching row: items the user started but did not finish. + /// + /// Restricted to movies and episodes unless ``JellyfinListOptions/includeItemKinds`` overrides + /// it, so audiobooks and other out-of-scope media never reach the home screen. + /// + /// - Parameter options: Honours `userID`, `parentID`, `startIndex`, `limit`, `fields`, and + /// `includeItemKinds`. + /// - Returns: One page of partially played items. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func resumeItems(_ options: JellyfinListOptions) async throws -> JellyfinMediaPage { + let kinds = options.includeItemKinds ?? [.movie, .episode] + let input = Operations.GetResumeItems.Input( + query: .init( + userId: options.userID, + startIndex: options.startIndex, + limit: options.limit, + parentId: options.parentID, + fields: options.fields?.generated, + includeItemTypes: kinds.generated + ) + ) + switch try await current.getResumeItems(input) { + case .ok(let response): + return JellyfinMediaPage(response.body.payload) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetResumeItems.id, + statusCode: statusCode + ) + } + } + + /// Fetches the Next Up row: the next unwatched episode of each in-progress series. + /// + /// - Parameters: + /// - seriesID: Restrict to one series; `nil` covers every series the user is watching. + /// - options: Honours `userID`, `parentID`, `startIndex`, `limit`, and `fields`. + /// - Returns: One page of next-up episodes. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func nextUp(seriesID: String?, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + let input = Operations.GetNextUp.Input( + query: .init( + userId: options.userID, + startIndex: options.startIndex, + limit: options.limit, + fields: options.fields?.generated, + seriesId: seriesID, + parentId: options.parentID + ) + ) + switch try await current.getNextUp(input) { + case .ok(let response): + return JellyfinMediaPage(response.body.payload) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetNextUp.id, + statusCode: statusCode + ) + } + } + + /// Fetches the Latest Media row: the most recently added items. + /// + /// This endpoint answers with a plain list rather than a page, so it carries no total count. + /// + /// - Parameter options: Honours `userID`, `parentID`, `limit`, `fields`, `includeItemKinds`, + /// and `groupItems`. + /// - Returns: The most recently added items, newest first. + /// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func latestMedia(_ options: JellyfinListOptions) async throws -> [JellyfinMediaItem] { + let input = Operations.GetLatestMedia.Input( + query: .init( + userId: options.userID, + parentId: options.parentID, + fields: options.fields?.generated, + includeItemTypes: options.includeItemKinds?.generated, + limit: options.limit, + groupItems: options.groupItems + ) + ) + switch try await current.getLatestMedia(input) { + case .ok(let response): + return response.body.payload.map(JellyfinMediaItem.init) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetLatestMedia.id, + statusCode: statusCode + ) + } + } + + /// Fetches the seasons of a series. + /// + /// - Parameters: + /// - seriesID: The series identifier. Must not be empty. + /// - options: Honours `userID` and `fields`. + /// - Returns: The seasons, in broadcast order. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `seriesID` is empty, or + /// ``JellyfinClientError/notFound`` if no such series exists. + package func seasons(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + guard !seriesID.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "seriesID") + } + let input = Operations.GetSeasons.Input( + path: .init(seriesId: seriesID), + query: .init(userId: options.userID, fields: options.fields?.generated) + ) + switch try await current.getSeasons(input) { + case .ok(let response): + return JellyfinMediaPage(response.body.payload) + case .notFound: + throw JellyfinClientError.notFound + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetSeasons.id, + statusCode: statusCode + ) + } + } + + /// Fetches the episodes of a series, optionally narrowed to one season. + /// + /// Pass either ``JellyfinListOptions/seasonID`` or ``JellyfinListOptions/season``; the server + /// accepts both spellings of "which season" and ignores the one left `nil`. + /// + /// - Parameters: + /// - seriesID: The series identifier. Must not be empty. + /// - options: Honours `userID`, `seasonID`, `season`, `startIndex`, `limit`, and `fields`. + /// - Returns: One page of episodes, in broadcast order. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `seriesID` is empty, or + /// ``JellyfinClientError/notFound`` if no such series exists. + package func episodes(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + guard !seriesID.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "seriesID") + } + let input = Operations.GetEpisodes.Input( + path: .init(seriesId: seriesID), + query: .init( + userId: options.userID, + fields: options.fields?.generated, + season: options.season, + seasonId: options.seasonID, + startIndex: options.startIndex, + limit: options.limit + ) + ) + switch try await current.getEpisodes(input) { + case .ok(let response): + return JellyfinMediaPage(response.body.payload) + case .notFound: + throw JellyfinClientError.notFound + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetEpisodes.id, + statusCode: statusCode + ) + } + } + + /// Runs a search-as-you-type query. + /// + /// People, genres, studios, and artists are explicitly excluded: Luminate searches movies and + /// TV only, and leaving those flags unset lets the server include them. + /// + /// - Parameters: + /// - term: The search text. Must not be empty or whitespace only. + /// - options: Honours `userID`, `startIndex`, `limit`, and `includeItemKinds`. + /// - Returns: The matching hints, best match first. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `term` is blank, or + /// ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func searchHints(term: String, options: JellyfinListOptions) async throws -> [JellyfinSearchHint] { + let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "term") + } + let input = Operations.GetSearchHints.Input( + query: .init( + startIndex: options.startIndex, + limit: options.limit, + userId: options.userID, + searchTerm: trimmed, + includeItemTypes: options.includeItemKinds?.generated, + includePeople: false, + includeMedia: true, + includeGenres: false, + includeStudios: false, + includeArtists: false + ) + ) + switch try await current.getSearchHints(input) { + case .ok(let response): + return (response.body.payload.searchHints ?? []).map(JellyfinSearchHint.init) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.GetSearchHints.id, + statusCode: statusCode + ) + } + } +} diff --git a/Sources/LuminateServices/Library/JellyfinClient+UserState.swift b/Sources/LuminateServices/Library/JellyfinClient+UserState.swift new file mode 100644 index 0000000..39965e2 --- /dev/null +++ b/Sources/LuminateServices/Library/JellyfinClient+UserState.swift @@ -0,0 +1,175 @@ +// +// JellyfinClient+UserState.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 LuminateAPI +import LuminateCore + +/// Watched state and favourites. +extension JellyfinClient { + /// Marks an item watched. + /// + /// Marking a season or series watched marks every episode beneath it, which is why this returns + /// the server's recomputed state rather than assuming the local one. + /// + /// - Parameters: + /// - itemID: The item to mark. Must not be empty. + /// - userID: Whose state to change; `nil` means the signed-in account. + /// - datePlayed: When it was watched; `nil` means now. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + package func markPlayed(itemID: String, userID: String?, datePlayed: Date?) async throws -> JellyfinUserData { + guard !itemID.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "itemID") + } + let input = Operations.MarkPlayedItem.Input( + path: .init(itemId: itemID), + query: .init(userId: userID, datePlayed: datePlayed) + ) + switch try await current.markPlayedItem(input) { + case .ok(let response): + return JellyfinUserData(response.body.payload) + case .notFound: + throw JellyfinClientError.notFound + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.MarkPlayedItem.id, + statusCode: statusCode + ) + } + } + + /// Marks an item unwatched and clears its resume position. + /// + /// - Parameters: + /// - itemID: The item to mark. Must not be empty. + /// - userID: Whose state to change; `nil` means the signed-in account. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/notFound`` if no such item exists. + package func markUnplayed(itemID: String, userID: String?) async throws -> JellyfinUserData { + guard !itemID.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "itemID") + } + let input = Operations.MarkUnplayedItem.Input( + path: .init(itemId: itemID), + query: .init(userId: userID) + ) + switch try await current.markUnplayedItem(input) { + case .ok(let response): + return JellyfinUserData(response.body.payload) + case .notFound: + throw JellyfinClientError.notFound + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.MarkUnplayedItem.id, + statusCode: statusCode + ) + } + } + + /// Adds an item to the user's favourites. + /// + /// - Parameters: + /// - itemID: The item to favourite. Must not be empty. + /// - userID: Whose favourites to change; `nil` means the signed-in account. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func markFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData { + guard !itemID.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "itemID") + } + let input = Operations.MarkFavoriteItem.Input( + path: .init(itemId: itemID), + query: .init(userId: userID) + ) + switch try await current.markFavoriteItem(input) { + case .ok(let response): + return JellyfinUserData(response.body.payload) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.MarkFavoriteItem.id, + statusCode: statusCode + ) + } + } + + /// Removes an item from the user's favourites. + /// + /// - Parameters: + /// - itemID: The item to unfavourite. Must not be empty. + /// - userID: Whose favourites to change; `nil` means the signed-in account. + /// - Returns: The item's updated playback state. + /// - Throws: ``JellyfinClientError/invalidArgument(name:)`` if `itemID` is empty, or + /// ``JellyfinClientError/unauthorized`` if the token is missing or rejected. + package func unmarkFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData { + guard !itemID.isEmpty else { + throw JellyfinClientError.invalidArgument(name: "itemID") + } + let input = Operations.UnmarkFavoriteItem.Input( + path: .init(itemId: itemID), + query: .init(userId: userID) + ) + switch try await current.unmarkFavoriteItem(input) { + case .ok(let response): + return JellyfinUserData(response.body.payload) + case .serviceUnavailable(let response): + throw JellyfinClientError.serviceUnavailable( + retryAfterSeconds: response.headers.retryAfter + ) + case .unauthorized: + throw JellyfinClientError.unauthorized + case .forbidden: + throw JellyfinClientError.forbidden + case .undocumented(let statusCode, _): + throw JellyfinClientError.unexpectedStatus( + operation: Operations.UnmarkFavoriteItem.id, + statusCode: statusCode + ) + } + } +} diff --git a/Sources/LuminateServices/Session/JellyfinAPI.swift b/Sources/LuminateServices/Session/JellyfinAPI.swift new file mode 100644 index 0000000..5271a3d --- /dev/null +++ b/Sources/LuminateServices/Session/JellyfinAPI.swift @@ -0,0 +1,213 @@ +// +// JellyfinAPI.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 LuminateAPI + +/// The slice of the generated Jellyfin API that ``JellyfinClient`` actually calls. +/// +/// The generated `APIProtocol` declares all 388 operations in the Jellyfin specification, which +/// makes it impossible to conform a test double to. This protocol names only the operations +/// Luminate uses, so a test can implement a handful of methods instead of hundreds. The generated +/// `Client` already has matching signatures, so it conforms with an empty extension. +/// +/// This is a service-layer seam and nothing more: it deals in generated `Operations.*` types, which +/// never travel above ``JellyfinClient``. +package protocol JellyfinAPI: Sendable { + /// Signs in with a username and password. + /// + /// - Parameter input: The generated `AuthenticateUserByName` input. + /// - Returns: The generated `AuthenticateUserByName` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func authenticateUserByName(_ input: Operations.AuthenticateUserByName.Input) async throws + -> Operations.AuthenticateUserByName.Output + + /// Redeems an approved Quick Connect secret for an access token. + /// + /// - Parameter input: The generated `AuthenticateWithQuickConnect` input. + /// - Returns: The generated `AuthenticateWithQuickConnect` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func authenticateWithQuickConnect(_ input: Operations.AuthenticateWithQuickConnect.Input) async throws + -> Operations.AuthenticateWithQuickConnect.Output + + /// Lists the accounts a server advertises before sign-in. + /// + /// - Parameter input: The generated `GetPublicUsers` input. + /// - Returns: The generated `GetPublicUsers` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getPublicUsers(_ input: Operations.GetPublicUsers.Input) async throws -> Operations.GetPublicUsers.Output + + /// Reads the server information available without authentication. + /// + /// - Parameter input: The generated `GetPublicSystemInfo` input. + /// - Returns: The generated `GetPublicSystemInfo` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getPublicSystemInfo(_ input: Operations.GetPublicSystemInfo.Input) async throws + -> Operations.GetPublicSystemInfo.Output + + /// Reads the full server information. + /// + /// - Parameter input: The generated `GetSystemInfo` input. + /// - Returns: The generated `GetSystemInfo` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getSystemInfo(_ input: Operations.GetSystemInfo.Input) async throws -> Operations.GetSystemInfo.Output + + /// Reports whether the server allows Quick Connect pairing. + /// + /// - Parameter input: The generated `GetQuickConnectEnabled` input. + /// - Returns: The generated `GetQuickConnectEnabled` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getQuickConnectEnabled(_ input: Operations.GetQuickConnectEnabled.Input) async throws + -> Operations.GetQuickConnectEnabled.Output + + /// Starts a Quick Connect pairing attempt. + /// + /// - Parameter input: The generated `InitiateQuickConnect` input. + /// - Returns: The generated `InitiateQuickConnect` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func initiateQuickConnect(_ input: Operations.InitiateQuickConnect.Input) async throws + -> Operations.InitiateQuickConnect.Output + + /// Polls an in-flight Quick Connect request. + /// + /// - Parameter input: The generated `GetQuickConnectState` input. + /// - Returns: The generated `GetQuickConnectState` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getQuickConnectState(_ input: Operations.GetQuickConnectState.Input) async throws + -> Operations.GetQuickConnectState.Output + + /// Changes or resets an account password. + /// + /// - Parameter input: The generated `UpdateUserPassword` input. + /// - Returns: The generated `UpdateUserPassword` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func updateUserPassword(_ input: Operations.UpdateUserPassword.Input) async throws + -> Operations.UpdateUserPassword.Output + + /// Lists the libraries a user can browse. + /// + /// - Parameter input: The generated `GetUserViews` input. + /// - Returns: The generated `GetUserViews` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getUserViews(_ input: Operations.GetUserViews.Input) async throws -> Operations.GetUserViews.Output + + /// Runs a browse, filter, or search query. + /// + /// - Parameter input: The generated `GetItems` input. + /// - Returns: The generated `GetItems` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getItems(_ input: Operations.GetItems.Input) async throws -> Operations.GetItems.Output + + /// Reads one item in full detail. + /// + /// - Parameter input: The generated `GetItem` input. + /// - Returns: The generated `GetItem` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getItem(_ input: Operations.GetItem.Input) async throws -> Operations.GetItem.Output + + /// Lists partially played items for the Continue Watching row. + /// + /// - Parameter input: The generated `GetResumeItems` input. + /// - Returns: The generated `GetResumeItems` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getResumeItems(_ input: Operations.GetResumeItems.Input) async throws -> Operations.GetResumeItems.Output + + /// Lists the next unwatched episode of each in-progress series. + /// + /// - Parameter input: The generated `GetNextUp` input. + /// - Returns: The generated `GetNextUp` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getNextUp(_ input: Operations.GetNextUp.Input) async throws -> Operations.GetNextUp.Output + + /// Lists the most recently added items. + /// + /// - Parameter input: The generated `GetLatestMedia` input. + /// - Returns: The generated `GetLatestMedia` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getLatestMedia(_ input: Operations.GetLatestMedia.Input) async throws -> Operations.GetLatestMedia.Output + + /// Lists the seasons of a series. + /// + /// - Parameter input: The generated `GetSeasons` input. + /// - Returns: The generated `GetSeasons` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getSeasons(_ input: Operations.GetSeasons.Input) async throws -> Operations.GetSeasons.Output + + /// Lists the episodes of a series. + /// + /// - Parameter input: The generated `GetEpisodes` input. + /// - Returns: The generated `GetEpisodes` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getEpisodes(_ input: Operations.GetEpisodes.Input) async throws -> Operations.GetEpisodes.Output + + /// Runs a search-as-you-type query. + /// + /// - Parameter input: The generated `GetSearchHints` input. + /// - Returns: The generated `GetSearchHints` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getSearchHints(_ input: Operations.GetSearchHints.Input) async throws -> Operations.GetSearchHints.Output + + /// Downloads the first image of a given type for an item. + /// + /// - Parameter input: The generated `GetItemImage` input. + /// - Returns: The generated `GetItemImage` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getItemImage(_ input: Operations.GetItemImage.Input) async throws -> Operations.GetItemImage.Output + + /// Downloads a specific image of a given type for an item. + /// + /// - Parameter input: The generated `GetItemImageByIndex` input. + /// - Returns: The generated `GetItemImageByIndex` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func getItemImageByIndex(_ input: Operations.GetItemImageByIndex.Input) async throws + -> Operations.GetItemImageByIndex.Output + + /// Marks an item watched. + /// + /// - Parameter input: The generated `MarkPlayedItem` input. + /// - Returns: The generated `MarkPlayedItem` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func markPlayedItem(_ input: Operations.MarkPlayedItem.Input) async throws -> Operations.MarkPlayedItem.Output + + /// Marks an item unwatched. + /// + /// - Parameter input: The generated `MarkUnplayedItem` input. + /// - Returns: The generated `MarkUnplayedItem` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func markUnplayedItem(_ input: Operations.MarkUnplayedItem.Input) async throws -> Operations.MarkUnplayedItem.Output + + /// Adds an item to a user's favourites. + /// + /// - Parameter input: The generated `MarkFavoriteItem` input. + /// - Returns: The generated `MarkFavoriteItem` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func markFavoriteItem(_ input: Operations.MarkFavoriteItem.Input) async throws -> Operations.MarkFavoriteItem.Output + + /// Removes an item from a user's favourites. + /// + /// - Parameter input: The generated `UnmarkFavoriteItem` input. + /// - Returns: The generated `UnmarkFavoriteItem` output, including every documented HTTP status. + /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. + func unmarkFavoriteItem(_ input: Operations.UnmarkFavoriteItem.Input) async throws + -> Operations.UnmarkFavoriteItem.Output +} + +/// The generated client already implements every operation with a matching signature. +extension Client: JellyfinAPI {} diff --git a/Sources/LuminateServices/Session/JellyfinClient.swift b/Sources/LuminateServices/Session/JellyfinClient.swift new file mode 100644 index 0000000..607ed1d --- /dev/null +++ b/Sources/LuminateServices/Session/JellyfinClient.swift @@ -0,0 +1,150 @@ +// +// 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 LuminateAPI +import LuminateCore +import OpenAPIRuntime +import OpenAPIURLSession + +/// Luminate's Jellyfin service: one configured server, one client identity, one access token. +/// +/// Construct it once, hand it to the view tree through the `\.client` environment slot, and call +/// its operations from anywhere: +/// +/// ```swift +/// let client = JellyfinClient( +/// configuration: JellyfinClientConfiguration(serverURL: serverURL) +/// ) +/// _ = try await client.authenticate(username: "echo", password: secret) +/// let libraries = try await client.libraries() +/// ``` +/// +/// ## Isolation +/// +/// This is an `actor` because the server URL and access token are mutable shared state that any +/// screen may read while onboarding is writing it. Actor isolation gives that safety without the +/// unchecked-conformance escape hatches the project bans. It is intentionally not `@Observable`: a +/// view that needs reactive session state should own a main-actor view model that calls this actor. +/// +/// ## Errors +/// +/// Documented HTTP statuses surface as ``JellyfinClientError``. Transport failures, decoding +/// failures, and oversized response bodies propagate from the OpenAPI runtime unchanged so their +/// diagnostics survive. +package actor JellyfinClient: JellyfinService { + /// The server URL, client identity, and access token in force. + private var configuration: JellyfinClientConfiguration + + /// Rebuilds the generated client whenever the configuration changes. + /// + /// Held as a closure so the production and test initialisers share one mutation path: the + /// production closure builds a real `Client` around the injected transport, the test closure + /// hands back the same double every time. + private let makeAPI: @Sendable (JellyfinClientConfiguration) -> any JellyfinAPI + + /// The generated client the operations call, rebuilt on every configuration change. + private var api: any JellyfinAPI + + /// Creates a client that talks to a real server. + /// + /// - Parameters: + /// - configuration: The server URL, client identity, and any persisted access token. + /// - transport: The HTTP transport; defaults to `URLSession`. + package init( + configuration: JellyfinClientConfiguration, + transport: any ClientTransport = URLSessionTransport() + ) { + let makeAPI: @Sendable (JellyfinClientConfiguration) -> any JellyfinAPI = { configuration in + Client( + serverURL: configuration.serverURL, + transport: transport, + middlewares: [ + AuthenticationMiddleware( + clientName: configuration.clientName, + deviceName: configuration.deviceName, + deviceID: configuration.deviceID, + version: configuration.version, + token: configuration.accessToken + ) + ] + ) + } + self.configuration = configuration + self.makeAPI = makeAPI + self.api = makeAPI(configuration) + } + + /// Creates a client backed by a supplied API implementation, for tests. + /// + /// The supplied implementation is reused across configuration changes, so a test can observe + /// ``setAccessToken(_:)`` without the double being rebuilt. + /// + /// - Parameters: + /// - configuration: The server URL, client identity, and any starting access token. + /// - api: The API implementation to call instead of a real client. + package init(configuration: JellyfinClientConfiguration, api: any JellyfinAPI) { + self.configuration = configuration + self.makeAPI = { _ in api } + self.api = api + } + + /// The server this client currently talks to. + package var serverURL: URL { configuration.serverURL } + + /// The access token in force, or `nil` before sign-in. + package var accessToken: String? { configuration.accessToken } + + /// Whether an access token is present. + /// + /// Reports only that a token exists, not that the server still honours it; an expired token + /// surfaces as ``JellyfinClientError/unauthorized`` on the next call. + package var isAuthenticated: Bool { configuration.accessToken?.isEmpty == false } + + /// Points the client at a different server and rebuilds the underlying API client. + /// + /// The access token is left untouched, because a saved profile may already hold a valid token + /// for the new server. Clear it with ``setAccessToken(_:)`` when switching to a server the + /// current token is not valid for. + /// + /// - Parameter serverURL: The new base URL. + package func setServerURL(_ serverURL: URL) { + configuration.serverURL = serverURL + api = makeAPI(configuration) + } + + /// Replaces the access token and rebuilds the underlying API client. + /// + /// An empty string is normalised to `nil` so ``isAuthenticated`` cannot report a token that is + /// really absent. Pass `nil` to sign out. + /// + /// - Parameter accessToken: The new token, or `nil` to clear it. + package func setAccessToken(_ accessToken: String?) { + let normalized = (accessToken?.isEmpty == true) ? nil : accessToken + configuration.accessToken = normalized + api = makeAPI(configuration) + } + + /// The generated client the operation extensions call. + /// + /// Actor-isolated, so every operation reads the client that matches the current configuration. + var current: any JellyfinAPI { api } +} diff --git a/Sources/LuminateServices/Session/JellyfinClientConfiguration.swift b/Sources/LuminateServices/Session/JellyfinClientConfiguration.swift new file mode 100644 index 0000000..c90f975 --- /dev/null +++ b/Sources/LuminateServices/Session/JellyfinClientConfiguration.swift @@ -0,0 +1,90 @@ +// +// JellyfinClientConfiguration.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 + +/// Everything a ``JellyfinClient`` needs to reach one server as one client identity. +/// +/// The identity fields end up in the `Authorization` header that ``AuthenticationMiddleware`` +/// builds. They are not cosmetic: the server lists ``deviceName`` in its active-devices UI and +/// keys the session by ``deviceID``. +package struct JellyfinClientConfiguration: Sendable, Hashable { + /// The base URL of the Jellyfin server, including scheme and any custom port. + package var serverURL: URL + /// The application name reported to the server. + package var clientName: String + /// The human-readable device name shown in the server's active-devices list. + package var deviceName: String + /// The stable per-machine device identifier the server keys its session by. + package var deviceID: String + /// The application version reported to the server. + package var version: String + /// The access token, or `nil` before sign-in. + package var accessToken: String? + + /// Creates a configuration, defaulting the client identity to Luminate's. + /// + /// - Parameters: + /// - serverURL: The base URL of the Jellyfin server. + /// - clientName: The application name reported to the server. + /// - deviceName: The device name shown by the server; defaults to the machine's host name. + /// - deviceID: The device identifier; defaults to ``defaultDeviceID()``. + /// - version: The application version reported to the server. + /// - accessToken: An access token to start authenticated with, if one was persisted. + package init( + serverURL: URL, + clientName: String = "Luminate", + deviceName: String = ProcessInfo.processInfo.hostName, + deviceID: String = JellyfinClientConfiguration.defaultDeviceID(), + version: String = "0.1.0", + accessToken: String? = nil + ) { + self.serverURL = serverURL + self.clientName = clientName + self.deviceName = deviceName + self.deviceID = deviceID + self.version = version + self.accessToken = accessToken + } + + /// Derives a device identifier that survives application restarts. + /// + /// Jellyfin opens a new server-side session for every unfamiliar device identifier, so a value + /// regenerated each launch would litter the server's device list. The machine ID is stable, + /// already present on every systemd and D-Bus system, and needs no persistence layer of + /// Luminate's own. + /// + /// - Returns: The contents of `/etc/machine-id`, else `/var/lib/dbus/machine-id`, else a fresh + /// UUID string when neither file is readable or both are empty. + package static func defaultDeviceID() -> String { + let candidates = ["/etc/machine-id", "/var/lib/dbus/machine-id"] + for candidate in candidates { + guard let contents = try? String(contentsOf: URL(filePath: candidate), encoding: .utf8) else { + continue + } + let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return UUID().uuidString + } +} diff --git a/Sources/LuminateServices/Session/JellyfinDomainMapping.swift b/Sources/LuminateServices/Session/JellyfinDomainMapping.swift new file mode 100644 index 0000000..277b7a8 --- /dev/null +++ b/Sources/LuminateServices/Session/JellyfinDomainMapping.swift @@ -0,0 +1,360 @@ +// +// JellyfinDomainMapping.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 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) } + ) + } +} diff --git a/Sources/LuminateServices/Session/JellyfinResponsePayload.swift b/Sources/LuminateServices/Session/JellyfinResponsePayload.swift new file mode 100644 index 0000000..dcc8f22 --- /dev/null +++ b/Sources/LuminateServices/Session/JellyfinResponsePayload.swift @@ -0,0 +1,289 @@ +// +// JellyfinResponsePayload.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 LuminateAPI + +// Collapses the redundant JSON content-type cases the Jellyfin specification produces. +// +// Jellyfin advertises three equivalent response content types for every JSON endpoint -- +// `application/json`, `application/json; profile="camelcase"`, and +// `application/json; profile="pascalcase"` -- so the generator emits three cases carrying the +// identical payload. Which one arrives depends on content negotiation and never on anything +// Luminate cares about, so every call site reads `body.payload` instead of switching three ways. +// +// The generated shorthand accessors (`output.ok`, `body.json`) are deliberately unused: they throw +// `OpenAPIRuntime.RuntimeError`, which is internal to the runtime, so a caller can neither match +// nor translate it. Switching the output explicitly keeps every failure expressible as a +// `JellyfinClientError`. +// +// These enums are `@frozen`, so each switch is exhaustive without a `default`. + +extension Operations.GetPublicSystemInfo.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.PublicSystemInfo { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetSystemInfo.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.SystemInfo { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetPublicUsers.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: [Components.Schemas.UserDto] { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.AuthenticateUserByName.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.AuthenticationResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.AuthenticateWithQuickConnect.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.AuthenticationResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetQuickConnectEnabled.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Swift.Bool { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.InitiateQuickConnect.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.QuickConnectResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetQuickConnectState.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.QuickConnectResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetUserViews.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.BaseItemDtoQueryResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetItems.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.BaseItemDtoQueryResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetItem.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.BaseItemDto { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetResumeItems.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.BaseItemDtoQueryResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetNextUp.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.BaseItemDtoQueryResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetLatestMedia.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: [Components.Schemas.BaseItemDto] { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetSeasons.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.BaseItemDtoQueryResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetEpisodes.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.BaseItemDtoQueryResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.GetSearchHints.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.SearchHintResult { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.MarkPlayedItem.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.UserItemDataDto { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.MarkUnplayedItem.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.UserItemDataDto { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.MarkFavoriteItem.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.UserItemDataDto { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} + +extension Operations.UnmarkFavoriteItem.Output.Ok.Body { + /// The decoded body, whichever JSON profile the server negotiated. + var payload: Components.Schemas.UserItemDataDto { + switch self { + case .json(let value), + .applicationJsonProfile_Quot_camelcase_quot_(let value), + .applicationJsonProfile_Quot_pascalcase_quot_(let value): + value + } + } +} diff --git a/Sources/LuminateUI/Environment/ClientEnvironmentKey.swift b/Sources/LuminateUI/Environment/ClientEnvironmentKey.swift new file mode 100644 index 0000000..5f1edbe --- /dev/null +++ b/Sources/LuminateUI/Environment/ClientEnvironmentKey.swift @@ -0,0 +1,62 @@ +// +// ClientEnvironmentKey.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 LuminateCore +import Portico + +/// The environment slot that carries the Jellyfin service down the view tree. +/// +/// Read it with `@Environment(\.client)` and inject it with `.environment(\.client, service)`. +package enum ClientEnvironmentKey: EnvironmentKey { + /// The placeholder used when no client has been injected. + /// + /// Every call on it throws ``JellyfinClientError/notConfigured``, so a subtree mounted without + /// `.environment(\.client, ...)` reports a precise failure at its first request instead of + /// silently talking to a placeholder host. + /// + /// Declared `nonisolated` because `LuminateUI` builds with `.defaultIsolation(MainActor.self)` + /// while the value it returns is a plain `Sendable` object; a `nonisolated` witness satisfies + /// the requirement whichever isolation `EnvironmentKey` itself carries. + nonisolated package static var defaultValue: any JellyfinService { + UnconfiguredJellyfinService.shared + } +} + +extension EnvironmentValues { + /// The Jellyfin service visible to this subtree. + /// + /// ```swift + /// // Injection, once, near the root: + /// RootView().environment(\.client, jellyfinClient) + /// + /// // Consumption, anywhere below it: + /// @Environment(\.client) private var jellyfinClient + /// ``` + /// + /// The accessors deliberately route through `self[ClientEnvironmentKey.self]`: Portico resolves + /// a key path by reading it with a probe installed and capturing the state box the subscript + /// hands over. A computed property that bypassed the subscript would leave the slot unresolvable + /// and make `@Environment(\.client)` trap at mount. + package var client: any JellyfinService { + get { self[ClientEnvironmentKey.self] } + set { self[ClientEnvironmentKey.self] = newValue } + } +} diff --git a/Tests/LuminateCoreTests/JellyfinModelTests.swift b/Tests/LuminateCoreTests/JellyfinModelTests.swift new file mode 100644 index 0000000..f2ec99b --- /dev/null +++ b/Tests/LuminateCoreTests/JellyfinModelTests.swift @@ -0,0 +1,101 @@ +// +// JellyfinModelTests.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 Testing + +@testable import LuminateCore + +/// Guards the wire spellings Luminate's domain enums promise the service layer. +/// +/// These raw values are the entire contract between ``LuminateCore`` and the generated Jellyfin +/// client: mapping is a `rawValue` round trip, so a typo here silently drops data at runtime rather +/// than failing to compile. +@Suite struct JellyfinModelTests { + @Test("Media kind raw values match the server spellings") + func mediaKindRawValues() { + #expect(JellyfinMediaKind.movie.rawValue == "Movie") + #expect(JellyfinMediaKind.boxSet.rawValue == "BoxSet") + #expect(JellyfinMediaKind.collectionFolder.rawValue == "CollectionFolder") + #expect(JellyfinMediaKind(rawValue: "Episode") == .episode) + #expect(JellyfinMediaKind(rawValue: "MusicAlbum") == nil) + } + + @Test("Collection type raw values are lowercase, as the server sends them") + func collectionTypeRawValues() { + #expect(JellyfinCollectionType(rawValue: "tvshows") == .tvShows) + #expect(JellyfinCollectionType(rawValue: "movies") == .movies) + #expect(JellyfinCollectionType(rawValue: "boxsets") == .boxSets) + #expect(JellyfinCollectionType(rawValue: "TvShows") == nil) + #expect(JellyfinCollectionType(rawValue: "music") == nil) + } + + @Test("Sort field and item field keep their irregular server spellings") + func irregularRawValues() { + #expect(JellyfinSortField.defaultOrder.rawValue == "Default") + #expect(JellyfinSortField.airedEpisodeOrder.rawValue == "AiredEpisodeOrder") + #expect(JellyfinItemField.isHighDefinition.rawValue == "IsHD") + #expect(JellyfinItemField.providerIDs.rawValue == "ProviderIds") + #expect(JellyfinItemField.parentID.rawValue == "ParentId") + } + + @Test("Image type and format raw values match the server spellings") + func imageRawValues() { + #expect(JellyfinImageType.primary.rawValue == "Primary") + #expect(JellyfinImageType.boxRear.rawValue == "BoxRear") + #expect(JellyfinImageFormat.jpg.rawValue == "Jpg") + #expect(JellyfinSortOrder.descending.rawValue == "Descending") + } + + @Test("An empty query sends nothing, so the server applies its own defaults") + func emptyQueryIsFullyUnset() { + let query = JellyfinMediaQuery() + #expect(query.userID == nil) + #expect(query.parentID == nil) + #expect(query.includeItemKinds == nil) + #expect(query.sortBy == nil) + #expect(query.limit == nil) + #expect(query.recursive == nil) + #expect(query.enableImageTypes == nil) + } + + @Test("Empty list options send nothing") + func emptyListOptionsAreFullyUnset() { + let options = JellyfinListOptions() + #expect(options.userID == nil) + #expect(options.parentID == nil) + #expect(options.seasonID == nil) + #expect(options.season == nil) + #expect(options.limit == nil) + #expect(options.fields == nil) + #expect(options.includeItemKinds == nil) + #expect(options.includeHidden == nil) + #expect(options.groupItems == nil) + } + + @Test("Collection defaults are empty rather than nil") + func collectionDefaults() { + #expect(JellyfinMediaPage().items.isEmpty) + #expect(JellyfinMediaPage().totalRecordCount == nil) + #expect(JellyfinMediaItem().imageTags.isEmpty) + #expect(JellyfinMediaItem().backdropImageTags.isEmpty) + #expect(JellyfinLibrary().backdropImageTags.isEmpty) + } +} diff --git a/Tests/LuminateCoreTests/UnconfiguredJellyfinServiceTests.swift b/Tests/LuminateCoreTests/UnconfiguredJellyfinServiceTests.swift new file mode 100644 index 0000000..eb8b2cb --- /dev/null +++ b/Tests/LuminateCoreTests/UnconfiguredJellyfinServiceTests.swift @@ -0,0 +1,57 @@ +// +// UnconfiguredJellyfinServiceTests.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 Testing + +@testable import LuminateCore + +/// Proves the placeholder service fails loudly instead of silently doing nothing. +/// +/// This is what a subtree sees when `.environment(\\.client, ...)` was forgotten, so every route +/// into it -- full-arity requirement and reduced-arity convenience alike -- must throw. +@Suite struct UnconfiguredJellyfinServiceTests { + private let service = UnconfiguredJellyfinService.shared + + @Test("Reports itself unauthenticated") + func neverAuthenticated() { + #expect(service.isAuthenticated == false) + } + + @Test("A full-arity requirement throws notConfigured") + func requirementThrows() async { + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.publicUsers() + } + } + + @Test("A convenience overload forwards to the requirement and still throws") + func convenienceThrows() async { + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.items() + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.image(itemID: "x", type: .primary) + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.libraries() + } + } +} diff --git a/Tests/LuminateServicesTests/AuthenticationMiddlewareTests.swift b/Tests/LuminateServicesTests/AuthenticationMiddlewareTests.swift new file mode 100644 index 0000000..f51b812 --- /dev/null +++ b/Tests/LuminateServicesTests/AuthenticationMiddlewareTests.swift @@ -0,0 +1,97 @@ +// +// AuthenticationMiddlewareTests.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 Testing + +@testable import LuminateServices + +/// Records the request a middleware forwarded, so a test can inspect it after the fact. +private actor RequestRecorder { + /// The last forwarded request. + private(set) var request: HTTPRequest? + + /// Stores a forwarded request. + /// + /// - Parameter request: The request the middleware passed down the chain. + func record(_ request: HTTPRequest) { + self.request = request + } +} + +/// Pins the exact `Authorization` header Jellyfin expects. +/// +/// Jellyfin rejects the request outright if this string is malformed, and the failure surfaces as a +/// bare 401 with no explanation, so the format is asserted literally rather than by parsing. +@Suite struct AuthenticationMiddlewareTests { + /// Runs `middleware` over a throwaway request and returns the `Authorization` header it set. + /// + /// - Parameter middleware: The middleware under test. + /// - Returns: The forwarded request's `Authorization` header, if it set one. + private func authorizationHeader(from middleware: AuthenticationMiddleware) async throws -> String? { + let recorder = RequestRecorder() + let request = HTTPRequest(method: .get, scheme: "http", authority: "localhost", path: "/System/Info/Public") + _ = try await middleware.intercept( + request, + body: nil, + baseURL: URL(string: "http://localhost")!, + operationID: "GetPublicSystemInfo" + ) { forwarded, _, _ in + await recorder.record(forwarded) + return (HTTPResponse(status: .ok), nil) + } + return await recorder.request?.headerFields[.authorization] + } + + @Test("Builds the MediaBrowser credential string with the token") + func authenticatedHeader() async throws { + let middleware = AuthenticationMiddleware( + clientName: "Luminate", + deviceName: "test-device", + deviceID: "test-device-id", + version: "0.1.0", + token: "test-token" + ) + let header = try await authorizationHeader(from: middleware) + #expect( + header + == #"MediaBrowser Client="Luminate", Device="test-device", DeviceId="test-device-id", Version="0.1.0", Token="test-token""# + ) + } + + @Test("Sends an empty token before sign-in rather than omitting the header") + func unauthenticatedHeader() async throws { + let middleware = AuthenticationMiddleware( + clientName: "Luminate", + deviceName: "test-device", + deviceID: "test-device-id", + version: "0.1.0", + token: nil + ) + let header = try await authorizationHeader(from: middleware) + #expect( + header + == #"MediaBrowser Client="Luminate", Device="test-device", DeviceId="test-device-id", Version="0.1.0", Token="""# + ) + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift b/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift new file mode 100644 index 0000000..4a83355 --- /dev/null +++ b/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift @@ -0,0 +1,115 @@ +// +// JellyfinClientAuthTests.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 LuminateAPI +import LuminateCore +import Testing + +@testable import LuminateServices + +/// Covers sign-in: argument validation, payload mapping, and token adoption. +@Suite struct JellyfinClientAuthTests { + /// Builds a client wired to a double, pointed at a URL that is never dialled. + /// + /// - Parameter api: The stubbed API. + /// - Returns: A client that routes every call to `api`. + private func makeClient(_ api: MockJellyfinAPI) -> JellyfinClient { + JellyfinClient( + configuration: JellyfinClientConfiguration( + serverURL: URL(string: "http://localhost")!, + deviceName: "test-device", + deviceID: "test-device-id" + ), + api: api + ) + } + + @Test("A successful sign-in maps the result and adopts the token") + func signInAdoptsToken() async throws { + var api = MockJellyfinAPI() + api.authenticateUserByNameOutput = .ok( + .init( + body: .json( + Components.Schemas.AuthenticationResult( + user: .init(value1: Components.Schemas.UserDto(name: "echo", id: "u1", hasPassword: true)), + accessToken: "tok", + serverId: "srv" + ) + ) + ) + ) + let client = makeClient(api) + + let result = try await client.authenticate(username: "echo", password: "hunter2") + + #expect(result.accessToken == "tok") + #expect(result.serverID == "srv") + #expect(result.user?.name == "echo") + #expect(result.user?.id == "u1") + #expect(await client.accessToken == "tok") + #expect(await client.isAuthenticated) + } + + @Test("A success with no token is a failure, not an unauthenticated success") + func missingTokenThrows() async { + var api = MockJellyfinAPI() + api.authenticateUserByNameOutput = .ok( + .init(body: .json(Components.Schemas.AuthenticationResult(accessToken: nil))) + ) + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.missingPayload(operation: "AuthenticateUserByName")) { + _ = try await client.authenticate(username: "echo", password: "hunter2") + } + #expect(await client.isAuthenticated == false) + } + + @Test("An empty username is rejected before any request is sent") + func emptyUsernameRejected() async { + let client = makeClient(MockJellyfinAPI()) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "username")) { + _ = try await client.authenticate(username: "", password: "hunter2") + } + } + + @Test("An empty Quick Connect secret is rejected before any request is sent") + func emptySecretRejected() async { + let client = makeClient(MockJellyfinAPI()) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "secret")) { + _ = try await client.quickConnectState(secret: "") + } + } + + @Test("Clearing the token signs the client out") + func clearingTokenSignsOut() async { + let client = makeClient(MockJellyfinAPI()) + + await client.setAccessToken("tok") + #expect(await client.isAuthenticated) + + await client.setAccessToken("") + #expect(await client.accessToken == nil) + #expect(await client.isAuthenticated == false) + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientErrorMappingTests.swift b/Tests/LuminateServicesTests/JellyfinClientErrorMappingTests.swift new file mode 100644 index 0000000..2364511 --- /dev/null +++ b/Tests/LuminateServicesTests/JellyfinClientErrorMappingTests.swift @@ -0,0 +1,90 @@ +// +// JellyfinClientErrorMappingTests.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 LuminateAPI +import LuminateCore +import OpenAPIRuntime +import Testing + +@testable import LuminateServices + +/// Covers the translation from generated HTTP outcomes to ``JellyfinClientError``. +/// +/// Every operation hand-writes its status switch, so this pins the shape those switches share. +@Suite struct JellyfinClientErrorMappingTests { + /// Builds a client wired to a double. + /// + /// - Parameter api: The stubbed API. + /// - Returns: A client that routes every call to `api`. + private func makeClient(_ api: MockJellyfinAPI) -> JellyfinClient { + JellyfinClient( + configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!), + api: api + ) + } + + @Test("A 401 maps to unauthorized") + func unauthorizedMaps() async { + var api = MockJellyfinAPI() + api.getItemsOutput = .unauthorized(.init()) + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.unauthorized) { + _ = try await client.items() + } + } + + @Test("A 403 maps to forbidden") + func forbiddenMaps() async { + var api = MockJellyfinAPI() + api.getItemsOutput = .forbidden(.init()) + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.forbidden) { + _ = try await client.items() + } + } + + @Test("An undocumented status is reported with its operation and code") + func undocumentedMaps() async { + var api = MockJellyfinAPI() + api.getItemsOutput = .undocumented(statusCode: 418, .init()) + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.unexpectedStatus(operation: "GetItems", statusCode: 418)) { + _ = try await client.items() + } + } + + @Test("A starting server surfaces its Retry-After hint") + func serviceUnavailableCarriesRetryAfter() async { + var api = MockJellyfinAPI() + api.getPublicSystemInfoOutput = .serviceUnavailable( + .init(headers: .init(retryAfter: 5), body: .html(HTTPBody("starting"))) + ) + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.serviceUnavailable(retryAfterSeconds: 5)) { + _ = try await client.publicServerInfo() + } + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientImageTests.swift b/Tests/LuminateServicesTests/JellyfinClientImageTests.swift new file mode 100644 index 0000000..417ae94 --- /dev/null +++ b/Tests/LuminateServicesTests/JellyfinClientImageTests.swift @@ -0,0 +1,89 @@ +// +// JellyfinClientImageTests.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 LuminateAPI +import LuminateCore +import OpenAPIRuntime +import Testing + +@testable import LuminateServices + +/// Covers artwork download, which is the one operation returning raw bytes rather than JSON. +@Suite struct JellyfinClientImageTests { + /// Builds a client wired to a double. + /// + /// - Parameter api: The stubbed API. + /// - Returns: A client that routes every call to `api`. + private func makeClient(_ api: MockJellyfinAPI) -> JellyfinClient { + JellyfinClient( + configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!), + api: api + ) + } + + @Test("Collects the streamed image body into bytes") + func collectsImageBytes() async throws { + var api = MockJellyfinAPI() + api.getItemImageOutput = .ok(.init(body: .image_Ast_(HTTPBody([0, 1, 2, 255] as [UInt8])))) + let client = makeClient(api) + + let data = try await client.image(itemID: "i1", type: .primary) + + #expect(data == Data([0, 1, 2, 255])) + } + + @Test("An index selects the indexed-image operation") + func indexedImageUsesIndexedOperation() async throws { + var api = MockJellyfinAPI() + api.getItemImageByIndexOutput = .ok(.init(body: .image_Ast_(HTTPBody([9, 9] as [UInt8])))) + let client = makeClient(api) + + let data = try await client.image( + itemID: "i1", + type: .backdrop, + index: 2, + request: JellyfinImageRequest(fillWidth: 300) + ) + + #expect(data == Data([9, 9])) + } + + @Test("A missing image maps to notFound") + func missingImageMapsToNotFound() async { + var api = MockJellyfinAPI() + api.getItemImageOutput = .notFound(.init(body: .json(Components.Schemas.ProblemDetails()))) + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.notFound) { + _ = try await client.image(itemID: "i1", type: .logo) + } + } + + @Test("An empty item identifier is rejected before any request is sent") + func emptyItemIdentifierRejected() async { + let client = makeClient(MockJellyfinAPI()) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) { + _ = try await client.image(itemID: "", type: .primary) + } + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientLibraryTests.swift b/Tests/LuminateServicesTests/JellyfinClientLibraryTests.swift new file mode 100644 index 0000000..2100d55 --- /dev/null +++ b/Tests/LuminateServicesTests/JellyfinClientLibraryTests.swift @@ -0,0 +1,149 @@ +// +// JellyfinClientLibraryTests.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 LuminateAPI +import LuminateCore +import Testing + +@testable import LuminateServices + +/// Covers the DTO-to-domain mapping that every browse surface depends on. +@Suite struct JellyfinClientLibraryTests { + /// A movie DTO exercising the wrapped enum, image tag, and user data payloads. + private var movieResult: Components.Schemas.BaseItemDtoQueryResult { + Components.Schemas.BaseItemDtoQueryResult( + items: [ + Components.Schemas.BaseItemDto( + name: "Arrival", + id: "i1", + overview: "Linguist meets heptapods.", + productionYear: 2016, + _type: .init(value1: .movie), + userData: .init(value1: Components.Schemas.UserItemDataDto(isFavorite: true, played: false)), + imageTags: .init(additionalProperties: ["Primary": "abc", "NotAnImageType": "zzz"]), + backdropImageTags: ["bd1"] + ) + ], + totalRecordCount: 1, + startIndex: 0 + ) + } + + /// Builds a client wired to a double. + /// + /// - Parameter api: The stubbed API. + /// - Returns: A client that routes every call to `api`. + private func makeClient(_ api: MockJellyfinAPI) -> JellyfinClient { + JellyfinClient( + configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!), + api: api + ) + } + + @Test("Maps an item, dropping image tags the client cannot name") + func mapsItems() async throws { + var api = MockJellyfinAPI() + api.getItemsOutput = .ok(.init(body: .json(movieResult))) + let client = makeClient(api) + + let page = try await client.items() + + #expect(page.totalRecordCount == 1) + let item = try #require(page.items.first) + #expect(item.id == "i1") + #expect(item.name == "Arrival") + #expect(item.kind == .movie) + #expect(item.productionYear == 2016) + #expect(item.imageTags == [.primary: "abc"]) + #expect(item.backdropImageTags == ["bd1"]) + #expect(item.userData?.isFavorite == true) + #expect(item.userData?.played == false) + } + + @Test("Every JSON profile the server may negotiate decodes identically") + func jsonProfilesCollapse() async throws { + var camelCase = MockJellyfinAPI() + camelCase.getItemsOutput = .ok(.init(body: .applicationJsonProfile_Quot_camelcase_quot_(movieResult))) + var pascalCase = MockJellyfinAPI() + pascalCase.getItemsOutput = .ok(.init(body: .applicationJsonProfile_Quot_pascalcase_quot_(movieResult))) + + let fromCamelCase = try await makeClient(camelCase).items() + let fromPascalCase = try await makeClient(pascalCase).items() + + #expect(fromCamelCase == fromPascalCase) + #expect(fromCamelCase.items.first?.kind == .movie) + } + + @Test("Maps libraries and drops unsupported collection types") + func mapsLibraries() async throws { + var api = MockJellyfinAPI() + api.getUserViewsOutput = .ok( + .init( + body: .json( + Components.Schemas.BaseItemDtoQueryResult( + items: [ + Components.Schemas.BaseItemDto( + name: "Shows", + id: "l1", + childCount: 12, + collectionType: .init(value1: .tvshows), + imageTags: .init(additionalProperties: ["Primary": "tag1"]) + ), + Components.Schemas.BaseItemDto( + name: "Music", + id: "l2", + collectionType: .init(value1: .music) + ), + ] + ) + ) + ) + ) + let client = makeClient(api) + + let libraries = try await client.libraries() + + #expect(libraries.count == 2) + #expect(libraries[0].collectionType == .tvShows) + #expect(libraries[0].primaryImageTag == "tag1") + #expect(libraries[0].childCount == 12) + #expect(libraries[1].collectionType == nil) + } + + @Test("A blank search term is rejected before any request is sent") + func blankSearchTermRejected() async { + let client = makeClient(MockJellyfinAPI()) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "term")) { + _ = try await client.searchHints(term: " ") + } + } + + @Test("An empty series identifier is rejected before any request is sent") + func emptySeriesIdentifierRejected() async { + let client = makeClient(MockJellyfinAPI()) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "seriesID")) { + _ = try await client.seasons(seriesID: "") + } + } +} diff --git a/Tests/LuminateServicesTests/MockJellyfinAPI.swift b/Tests/LuminateServicesTests/MockJellyfinAPI.swift new file mode 100644 index 0000000..7e4a63a --- /dev/null +++ b/Tests/LuminateServicesTests/MockJellyfinAPI.swift @@ -0,0 +1,230 @@ +// +// MockJellyfinAPI.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 LuminateAPI +import LuminateServices +import Testing + +/// A ``JellyfinAPI`` double that answers with pre-baked generated outputs. +/// +/// Only the operations a test actually stubs are usable; every other call fails the test with a +/// clear message rather than returning a fabricated success. Outputs are plain `Sendable` values, +/// so the whole double is a `Sendable` struct with no isolation escape hatches. +struct MockJellyfinAPI: JellyfinAPI { + /// The output returned by ``authenticateUserByName(_:)``. + var authenticateUserByNameOutput: Operations.AuthenticateUserByName.Output? + + /// The output returned by ``authenticateWithQuickConnect(_:)``. + var authenticateWithQuickConnectOutput: Operations.AuthenticateWithQuickConnect.Output? + + /// The output returned by ``getPublicUsers(_:)``. + var getPublicUsersOutput: Operations.GetPublicUsers.Output? + + /// The output returned by ``getPublicSystemInfo(_:)``. + var getPublicSystemInfoOutput: Operations.GetPublicSystemInfo.Output? + + /// The output returned by ``getSystemInfo(_:)``. + var getSystemInfoOutput: Operations.GetSystemInfo.Output? + + /// The output returned by ``getQuickConnectEnabled(_:)``. + var getQuickConnectEnabledOutput: Operations.GetQuickConnectEnabled.Output? + + /// The output returned by ``initiateQuickConnect(_:)``. + var initiateQuickConnectOutput: Operations.InitiateQuickConnect.Output? + + /// The output returned by ``getQuickConnectState(_:)``. + var getQuickConnectStateOutput: Operations.GetQuickConnectState.Output? + + /// The output returned by ``updateUserPassword(_:)``. + var updateUserPasswordOutput: Operations.UpdateUserPassword.Output? + + /// The output returned by ``getUserViews(_:)``. + var getUserViewsOutput: Operations.GetUserViews.Output? + + /// The output returned by ``getItems(_:)``. + var getItemsOutput: Operations.GetItems.Output? + + /// The output returned by ``getItem(_:)``. + var getItemOutput: Operations.GetItem.Output? + + /// The output returned by ``getResumeItems(_:)``. + var getResumeItemsOutput: Operations.GetResumeItems.Output? + + /// The output returned by ``getNextUp(_:)``. + var getNextUpOutput: Operations.GetNextUp.Output? + + /// The output returned by ``getLatestMedia(_:)``. + var getLatestMediaOutput: Operations.GetLatestMedia.Output? + + /// The output returned by ``getSeasons(_:)``. + var getSeasonsOutput: Operations.GetSeasons.Output? + + /// The output returned by ``getEpisodes(_:)``. + var getEpisodesOutput: Operations.GetEpisodes.Output? + + /// The output returned by ``getSearchHints(_:)``. + var getSearchHintsOutput: Operations.GetSearchHints.Output? + + /// The output returned by ``getItemImage(_:)``. + var getItemImageOutput: Operations.GetItemImage.Output? + + /// The output returned by ``getItemImageByIndex(_:)``. + var getItemImageByIndexOutput: Operations.GetItemImageByIndex.Output? + + /// The output returned by ``markPlayedItem(_:)``. + var markPlayedItemOutput: Operations.MarkPlayedItem.Output? + + /// The output returned by ``markUnplayedItem(_:)``. + var markUnplayedItemOutput: Operations.MarkUnplayedItem.Output? + + /// The output returned by ``markFavoriteItem(_:)``. + var markFavoriteItemOutput: Operations.MarkFavoriteItem.Output? + + /// The output returned by ``unmarkFavoriteItem(_:)``. + var unmarkFavoriteItemOutput: Operations.UnmarkFavoriteItem.Output? + + /// Returns a stubbed output, or fails the test naming the unstubbed operation. + /// + /// - Parameters: + /// - output: The stubbed output for the operation, if the test supplied one. + /// - operation: The operation identifier, used in the failure message. + /// - Returns: The stubbed output. + /// - Throws: An error that fails the test when the operation was not stubbed. + private func unwrap(_ output: Output?, _ operation: String) throws -> Output { + try #require(output, "MockJellyfinAPI received an unstubbed call to \(operation)") + } + + func authenticateUserByName(_ input: Operations.AuthenticateUserByName.Input) async throws + -> Operations.AuthenticateUserByName.Output + { + try unwrap(authenticateUserByNameOutput, "AuthenticateUserByName") + } + + func authenticateWithQuickConnect(_ input: Operations.AuthenticateWithQuickConnect.Input) async throws + -> Operations.AuthenticateWithQuickConnect.Output + { + try unwrap(authenticateWithQuickConnectOutput, "AuthenticateWithQuickConnect") + } + + func getPublicUsers(_ input: Operations.GetPublicUsers.Input) async throws -> Operations.GetPublicUsers.Output { + try unwrap(getPublicUsersOutput, "GetPublicUsers") + } + + func getPublicSystemInfo(_ input: Operations.GetPublicSystemInfo.Input) async throws + -> Operations.GetPublicSystemInfo.Output + { + try unwrap(getPublicSystemInfoOutput, "GetPublicSystemInfo") + } + + func getSystemInfo(_ input: Operations.GetSystemInfo.Input) async throws -> Operations.GetSystemInfo.Output { + try unwrap(getSystemInfoOutput, "GetSystemInfo") + } + + func getQuickConnectEnabled(_ input: Operations.GetQuickConnectEnabled.Input) async throws + -> Operations.GetQuickConnectEnabled.Output + { + try unwrap(getQuickConnectEnabledOutput, "GetQuickConnectEnabled") + } + + func initiateQuickConnect(_ input: Operations.InitiateQuickConnect.Input) async throws + -> Operations.InitiateQuickConnect.Output + { + try unwrap(initiateQuickConnectOutput, "InitiateQuickConnect") + } + + func getQuickConnectState(_ input: Operations.GetQuickConnectState.Input) async throws + -> Operations.GetQuickConnectState.Output + { + try unwrap(getQuickConnectStateOutput, "GetQuickConnectState") + } + + func updateUserPassword(_ input: Operations.UpdateUserPassword.Input) async throws + -> Operations.UpdateUserPassword.Output + { + try unwrap(updateUserPasswordOutput, "UpdateUserPassword") + } + + func getUserViews(_ input: Operations.GetUserViews.Input) async throws -> Operations.GetUserViews.Output { + try unwrap(getUserViewsOutput, "GetUserViews") + } + + func getItems(_ input: Operations.GetItems.Input) async throws -> Operations.GetItems.Output { + try unwrap(getItemsOutput, "GetItems") + } + + func getItem(_ input: Operations.GetItem.Input) async throws -> Operations.GetItem.Output { + try unwrap(getItemOutput, "GetItem") + } + + func getResumeItems(_ input: Operations.GetResumeItems.Input) async throws -> Operations.GetResumeItems.Output { + try unwrap(getResumeItemsOutput, "GetResumeItems") + } + + func getNextUp(_ input: Operations.GetNextUp.Input) async throws -> Operations.GetNextUp.Output { + try unwrap(getNextUpOutput, "GetNextUp") + } + + func getLatestMedia(_ input: Operations.GetLatestMedia.Input) async throws -> Operations.GetLatestMedia.Output { + try unwrap(getLatestMediaOutput, "GetLatestMedia") + } + + func getSeasons(_ input: Operations.GetSeasons.Input) async throws -> Operations.GetSeasons.Output { + try unwrap(getSeasonsOutput, "GetSeasons") + } + + func getEpisodes(_ input: Operations.GetEpisodes.Input) async throws -> Operations.GetEpisodes.Output { + try unwrap(getEpisodesOutput, "GetEpisodes") + } + + func getSearchHints(_ input: Operations.GetSearchHints.Input) async throws -> Operations.GetSearchHints.Output { + try unwrap(getSearchHintsOutput, "GetSearchHints") + } + + func getItemImage(_ input: Operations.GetItemImage.Input) async throws -> Operations.GetItemImage.Output { + try unwrap(getItemImageOutput, "GetItemImage") + } + + func getItemImageByIndex(_ input: Operations.GetItemImageByIndex.Input) async throws + -> Operations.GetItemImageByIndex.Output + { + try unwrap(getItemImageByIndexOutput, "GetItemImageByIndex") + } + + func markPlayedItem(_ input: Operations.MarkPlayedItem.Input) async throws -> Operations.MarkPlayedItem.Output { + try unwrap(markPlayedItemOutput, "MarkPlayedItem") + } + + func markUnplayedItem(_ input: Operations.MarkUnplayedItem.Input) async throws -> Operations.MarkUnplayedItem.Output + { + try unwrap(markUnplayedItemOutput, "MarkUnplayedItem") + } + + func markFavoriteItem(_ input: Operations.MarkFavoriteItem.Input) async throws -> Operations.MarkFavoriteItem.Output + { + try unwrap(markFavoriteItemOutput, "MarkFavoriteItem") + } + + func unmarkFavoriteItem(_ input: Operations.UnmarkFavoriteItem.Input) async throws + -> Operations.UnmarkFavoriteItem.Output + { + try unwrap(unmarkFavoriteItemOutput, "UnmarkFavoriteItem") + } +} diff --git a/Tests/LuminateTests/LuminateTests.swift b/Tests/LuminateTests/LuminateTests.swift deleted file mode 100644 index b9c5951..0000000 --- a/Tests/LuminateTests/LuminateTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Testing -@testable import Luminate - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://swiftpackageindex.com/swiftlang/swift-testing/documentation -} diff --git a/Tests/LuminateUITests/ClientEnvironmentKeyTests.swift b/Tests/LuminateUITests/ClientEnvironmentKeyTests.swift new file mode 100644 index 0000000..6bc26d9 --- /dev/null +++ b/Tests/LuminateUITests/ClientEnvironmentKeyTests.swift @@ -0,0 +1,103 @@ +// +// ClientEnvironmentKeyTests.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 LuminateCore +@_spi(Portico) import Portico +import Testing + +@testable import LuminateUI + +/// A ``JellyfinService`` that exists only to be told apart from the default. +private final class StubJellyfinService: JellyfinService { + var isAuthenticated: Bool { true } + + func publicServerInfo() async throws -> JellyfinServerInfo { JellyfinServerInfo() } + func serverInfo() async throws -> JellyfinServerInfo { JellyfinServerInfo() } + func publicUsers() async throws -> [JellyfinUser] { [] } + func authenticate(username: String, password: String) async throws -> JellyfinAuthentication { + JellyfinAuthentication(accessToken: "stub") + } + func quickConnectEnabled() async throws -> Bool { false } + func initiateQuickConnect() async throws -> JellyfinQuickConnectState { JellyfinQuickConnectState() } + func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState { JellyfinQuickConnectState() } + func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication { + JellyfinAuthentication(accessToken: "stub") + } + func updateUserPassword( + userID: String?, + currentPassword: String?, + currentPIN: String?, + newPassword: String?, + resetPassword: Bool? + ) async throws {} + func libraries(_ options: JellyfinListOptions) async throws -> [JellyfinLibrary] { [] } + func items(_ query: JellyfinMediaQuery) async throws -> JellyfinMediaPage { JellyfinMediaPage() } + func item(id: String, userID: String?) async throws -> JellyfinMediaItem { JellyfinMediaItem() } + func resumeItems(_ options: JellyfinListOptions) async throws -> JellyfinMediaPage { JellyfinMediaPage() } + func nextUp(seriesID: String?, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + JellyfinMediaPage() + } + func latestMedia(_ options: JellyfinListOptions) async throws -> [JellyfinMediaItem] { [] } + func seasons(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + JellyfinMediaPage() + } + func episodes(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + JellyfinMediaPage() + } + func searchHints(term: String, options: JellyfinListOptions) async throws -> [JellyfinSearchHint] { [] } + func image( + itemID: String, + type: JellyfinImageType, + index: Int32?, + request: JellyfinImageRequest + ) async throws -> Data { + Data() + } + func markPlayed(itemID: String, userID: String?, datePlayed: Date?) async throws -> JellyfinUserData { + JellyfinUserData() + } + func markUnplayed(itemID: String, userID: String?) async throws -> JellyfinUserData { JellyfinUserData() } + func markFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData { JellyfinUserData() } + func unmarkFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData { JellyfinUserData() } +} + +/// Guards the `\\.client` environment slot itself. +/// +/// No widgets are constructed, so these run on a headless machine without `Gtk.initCheck()`. +@MainActor @Suite struct ClientEnvironmentKeyTests { + @Test("An unresolved slot falls back to the unconfigured service") + func defaultIsUnconfigured() { + #expect(EnvironmentValues().client is UnconfiguredJellyfinService) + } + + @Test("The key path routes through the environment subscript") + func keyPathResolvesToABox() { + #expect(EnvironmentValues()._box(for: \.client) != nil) + } + + @Test("An injected service replaces the default") + func injectionOverridesDefault() { + var values = EnvironmentValues() + values.client = StubJellyfinService() + #expect(values.client is StubJellyfinService) + } +}