363 lines
16 KiB
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
|
|
)
|
|
}
|
|
}
|
|
}
|