luminate/Sources/LuminateCore/Protocols/JellyfinService.swift

291 lines
15 KiB
Swift

//
// JellyfinService.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
/// 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
}