luminate/Sources/LuminateServices/Library/JellyfinClient+Library.swift

363 lines
16 KiB
Swift

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