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