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