├── .swift-version
├── Example
├── ExamplePackage
│ ├── Sources
│ │ └── ExamplePackage
│ │ │ └── ExamplePackage.swift
│ ├── .gitignore
│ ├── Tests
│ │ └── ExamplePackageTests
│ │ │ └── ExamplePackageTests.swift
│ └── Package.swift
├── Example
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── Services
│ │ ├── Router.swift
│ │ └── Player.swift
│ ├── Environment
│ │ ├── IsPreview.swift
│ │ ├── ServerInfo.swift
│ │ └── Client.swift
│ ├── ExampleApp.swift
│ └── Views
│ │ ├── LibrariesView.swift
│ │ ├── Misc
│ │ └── Cover.swift
│ │ ├── LibraryView.swift
│ │ ├── BookDetailView.swift
│ │ └── SignInView.swift
└── Example.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcuserdata
│ └── lcharlick.xcuserdatad
│ └── xcschemes
│ └── xcschememanagement.plist
├── README.md
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── Tests
└── AudiobookshelfKitTests
│ ├── Resources
│ ├── create_bookmark.json
│ ├── update_bookmark.json
│ ├── sync_local_session.json
│ ├── media_progress.json
│ ├── library_authors.json
│ ├── user.json
│ ├── libraries.json
│ ├── library_series.json
│ ├── login.json
│ └── author.json
│ ├── BaseTestCase.swift
│ ├── Requests
│ ├── RefreshAccessTokenTests.swift
│ ├── PingTests.swift
│ ├── RemoveBookmarkTests.swift
│ ├── GetAuthorImageTests.swift
│ ├── GetLibraryItemCoverTests.swift
│ ├── GetLibraryItemFileTests.swift
│ ├── StatusTests.swift
│ ├── CreateBookmarkTests.swift
│ ├── UpdateBookmarkTests.swift
│ ├── GetLibraryAuthorsTests.swift
│ ├── OAuth2CallbackTests.swift
│ ├── GetLibraryCollectionsTests.swift
│ ├── GetMediaProgressTests.swift
│ ├── BatchUpdateMediaProgressTests.swift
│ ├── OAuth2AuthorizationRequestTests.swift
│ ├── GetAuthorTests.swift
│ ├── GetUserTests.swift
│ ├── GetLibrarySeriesTests.swift
│ ├── GetLibrariesTests.swift
│ ├── GetLibraryPlaylistsTests.swift
│ ├── GetLibraryItemsTests.swift
│ ├── SyncLocalSessionTests.swift
│ └── GetUserPlaybackSessionsTests.swift
│ └── Util.swift
├── Sources
└── AudiobookshelfKit
│ ├── Models
│ ├── AuthorMinified.swift
│ ├── MediaType.swift
│ ├── SeriesSequence.swift
│ ├── PlayMethod.swift
│ ├── Chapter.swift
│ ├── Series.swift
│ ├── Track.swift
│ ├── Folder.swift
│ ├── Bookmark.swift
│ ├── LoginResponse.swift
│ ├── LibraryFile.swift
│ ├── Permissions.swift
│ ├── LibrarySettings.swift
│ ├── Collection.swift
│ ├── AuthorExpanded.swift
│ ├── AuthMethod.swift
│ ├── Author.swift
│ ├── Library.swift
│ ├── Playlist.swift
│ ├── MediaProgress.swift
│ ├── User.swift
│ ├── PlaybackSession.swift
│ ├── DeviceInfo.swift
│ ├── LibraryItemExpanded.swift
│ ├── AudioFile.swift
│ ├── ServerSettings.swift
│ └── LibraryItem.swift
│ ├── Extensions
│ ├── ComparableExtensions.swift
│ ├── URLQueryItemExtensions.swift
│ └── URLExtensions.swift
│ ├── Requests
│ ├── GetUser.swift
│ ├── Ping.swift
│ ├── GetLibraries.swift
│ ├── GetAuthor.swift
│ ├── GetMediaProgress.swift
│ ├── GetLibraryItem.swift
│ ├── RefreshAccessToken.swift
│ ├── Login.swift
│ ├── GetLibraryItemFile.swift
│ ├── RemoveBookmark.swift
│ ├── OAuth2Callback.swift
│ ├── CreateBookmark.swift
│ ├── GetUserPlaybackSessions.swift
│ ├── UpdateBookmark.swift
│ ├── GetLibraryPlaylists.swift
│ ├── OAuth2AuthorizationRequest.swift
│ ├── Status.swift
│ ├── GetLibraryAuthors.swift
│ ├── GetAuthorImage.swift
│ ├── GetLibraryItemCover.swift
│ ├── GetLibrarySeries.swift
│ ├── BatchUpdateMediaProgress.swift
│ ├── _Request.swift
│ ├── GetLibraryCollections.swift
│ ├── GetLibraryItems.swift
│ └── SyncLocalSession.swift
│ └── AudiobookshelfKit.swift
├── Package.swift
├── LICENSE
├── .gitignore
└── CLAUDE.md
/.swift-version:
--------------------------------------------------------------------------------
1 | 6.1
--------------------------------------------------------------------------------
/Example/ExamplePackage/Sources/ExamplePackage/ExamplePackage.swift:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Work in progress Audiobookshelf API client and example app. Will be used as the foundation for Prologue's Audiobookshelf support.
--------------------------------------------------------------------------------
/Example/Example/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/create_bookmark.json:
--------------------------------------------------------------------------------
1 | {
2 | "libraryItemId": "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458",
3 | "title": "Hello, world!",
4 | "time": 6446,
5 | "createdAt": 1723386963225
6 | }
7 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/update_bookmark.json:
--------------------------------------------------------------------------------
1 | {
2 | "libraryItemId": "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458",
3 | "title": "Hello, world!!",
4 | "time": 6446,
5 | "createdAt": 1723386963225
6 | }
7 |
--------------------------------------------------------------------------------
/Example/ExamplePackage/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/AuthorMinified.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthorMinified.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | public struct AuthorMinified: Codable, Hashable, Identifiable, Sendable {
10 | public let id: String
11 | public let name: String
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/MediaType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MediaType.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public enum MediaType: String, Codable, Sendable {
12 | case book
13 | case podcast
14 | case podcastEpisode
15 | }
16 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/sync_local_session.json:
--------------------------------------------------------------------------------
1 | {
2 | "results": [
3 | {
4 | "id": "play_session_1",
5 | "success": true,
6 | "error": null,
7 | "progressSynced": true
8 | },
9 | {
10 | "id": "play_session_2",
11 | "success": false,
12 | "error": "Session not found",
13 | "progressSynced": null
14 | }
15 | ]
16 | }
--------------------------------------------------------------------------------
/Example/Example/Services/Router.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Router.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 12/4/2024.
6 | //
7 |
8 | import Foundation
9 | import Observation
10 |
11 | @Observable class Router {
12 | var path = [Route]()
13 | }
14 |
15 | enum Route: Hashable {
16 | case libraries
17 | case library(id: String)
18 | case book(id: String, title: String)
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/SeriesSequence.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SeriesSequence.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | public struct SeriesSequence: Codable, Hashable, Identifiable, Sendable {
10 | public let id: String
11 | public let name: String
12 | public let sequence: String?
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/PlayMethod.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PlayMethod.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public enum PlayMethod: Int, Codable, Sendable {
12 | case directPlay = 0
13 | case directStream = 1
14 | case transcode = 2
15 | case local = 3
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Extensions/ComparableExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ComparableExtensions.swift
3 | // PlexKit
4 | //
5 | // Created by Lachlan Charlick on 31/5/20.
6 | // Copyright © 2020 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Comparable {
12 | func clamped(to range: ClosedRange) -> Self {
13 | min(max(range.lowerBound, self), range.upperBound)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Example/Example/Environment/IsPreview.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IsPreview.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 23/4/2024.
6 | //
7 |
8 | import SwiftUI
9 |
10 | private struct IsPreviewKey: EnvironmentKey {
11 | static let defaultValue = false
12 | }
13 |
14 | extension EnvironmentValues {
15 | var isPreview: Bool {
16 | get { self[IsPreviewKey.self] }
17 | set { self[IsPreviewKey.self] = newValue }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Chapter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Chapter.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Chapter: Codable, Hashable, Identifiable, Sendable {
12 | public let id: Int
13 | public let start: TimeInterval
14 | public let end: TimeInterval
15 | public let title: String
16 | }
17 |
--------------------------------------------------------------------------------
/Example/ExamplePackage/Tests/ExamplePackageTests/ExamplePackageTests.swift:
--------------------------------------------------------------------------------
1 | @testable import ExamplePackage
2 | import XCTest
3 |
4 | final class ExamplePackageTests: XCTestCase {
5 | func testExample() throws {
6 | // XCTest Documentation
7 | // https://developer.apple.com/documentation/xctest
8 |
9 | // Defining Test Cases and Test Methods
10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Example/Example/Environment/ServerInfo.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ServerInfo.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 23/4/2024.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | @Observable class ServerInfo {
12 | var url: URL!
13 | var token: String!
14 |
15 | init(url: URL, token: String) {
16 | self.url = url
17 | self.token = token
18 | }
19 |
20 | init() {}
21 |
22 | static let mock = ServerInfo(url: URL(string: "https://abs.myserver.com")!, token: "12345")
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Extensions/URLQueryItemExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLQueryItemExtensions.swift
3 | // PlexKit
4 | //
5 | // Created by Lachlan Charlick on 29/5/20.
6 | // Copyright © 2020 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension URLQueryItem {
12 | init(name: String, value: Bool) {
13 | self.init(name: name, value: value ? "1" : "0")
14 | }
15 |
16 | init(name: String, value: Int) {
17 | self.init(name: name, value: String(value))
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Example/Example/Environment/Client.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Client.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 23/4/2024.
6 | //
7 |
8 | import AudiobookshelfKit
9 | import SwiftUI
10 |
11 | private struct ClientKey: EnvironmentKey {
12 | static var defaultValue: Audiobookshelf { Audiobookshelf(sessionConfiguration: .default) }
13 | }
14 |
15 | extension EnvironmentValues {
16 | var client: Audiobookshelf {
17 | get { self[ClientKey.self] }
18 | set { self[ClientKey.self] = newValue }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetUser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetUser.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 30/7/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves your user.
13 | struct GetUser: ResourceRequest {
14 | public let path = "api/me"
15 | public init() {}
16 | }
17 | }
18 |
19 | public extension Audiobookshelf.Request.GetUser {
20 | typealias Response = User
21 | }
22 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/BaseTestCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseTestCase.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import XCTest
11 |
12 | let testURL = URL(string: "http://192.168.0.100:32400")!
13 |
14 | func loadResponse(
15 | _ name: String,
16 | for _: R.Type
17 | ) throws -> R.Response {
18 | let data = try loadResource(name, ext: "json")
19 | return try R.response(from: data)
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Extensions/URLExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLExtensions.swift
3 | // PlexKit
4 | //
5 | // Created by Lachlan Charlick on 30/5/20.
6 | // Copyright © 2020 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension URL {
12 | func appendingQueryItems(_ items: [URLQueryItem]) -> URL? {
13 | guard var comps = URLComponents(url: self, resolvingAgainstBaseURL: true) else {
14 | return nil
15 | }
16 | comps.queryItems = (comps.queryItems ?? []) + items
17 | return comps.url
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Series.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Series.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 1/3/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Series: Codable, Identifiable, Sendable {
12 | public let id: String
13 | public let name: String
14 | public let nameIgnorePrefix: String
15 | public let description: String?
16 | public let addedAt: Date
17 | public let updatedAt: Date
18 | public let libraryId: String
19 | public let books: [LibraryItem]
20 | }
21 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/media_progress.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "8e7ed07e-19b4-4ccf-9668-03a7ed20f1dd",
3 | "userId": "a7ea108d-ed8c-4179-bedd-07cf4db866aa",
4 | "libraryItemId": "27bec22a-0902-4226-a6ba-677a95fd993d",
5 | "episodeId": null,
6 | "mediaItemId": "66d835f9-5899-41e2-8184-2b235f6ad998",
7 | "mediaItemType": "book",
8 | "duration": 102531.286,
9 | "progress": 0.00006908974757226784,
10 | "currentTime": 7.083860668,
11 | "isFinished": false,
12 | "hideFromContinueListening": false,
13 | "lastUpdate": 1660451502259,
14 | "startedAt": 1660451494311,
15 | "finishedAt": null
16 | }
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Track.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Track.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Track: Codable, Hashable, Sendable {
12 | public let index: Int
13 | public let startOffset: TimeInterval
14 | public let duration: TimeInterval
15 | public let title: String
16 | public let contentUrl: String
17 | public let mimeType: String
18 | public let codec: String
19 | public let metadata: LibraryFile.Metadata
20 | }
21 |
--------------------------------------------------------------------------------
/Example/ExamplePackage/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.10
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "ExamplePackage",
7 | platforms: [.iOS(.v16)],
8 | products: [
9 | .library(
10 | name: "ExamplePackage",
11 | targets: ["ExamplePackage"]
12 | ),
13 | ],
14 | dependencies: [
15 | .package(path: "../.."),
16 | ],
17 | targets: [
18 | .target(
19 | name: "ExamplePackage",
20 | dependencies: [
21 | "AudiobookshelfKit",
22 | ]
23 | ),
24 | ]
25 | )
26 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/xcuserdata/lcharlick.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | Example.xcscheme
8 |
9 | orderHint
10 | 0
11 |
12 |
13 | SuppressBuildableAutocreation
14 |
15 | 48010CF92BC5856B00A8468C
16 |
17 | primary
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Folder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Folder.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Folder: Codable, Hashable, Sendable {
12 | /// The ID of the folder.
13 | public let id: String
14 | /// The path on the server for the folder.
15 | public let fullPath: String
16 | /// The ID of the library the folder belongs to.
17 | public let libraryId: String
18 | /// The time (in ms since POSIX epoch) when the folder was added.
19 | public let addedAt: Date
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Bookmark.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Bookmark.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Bookmark: Codable, Hashable, Sendable {
12 | /// The ID of the library item the bookmark is for.
13 | public let libraryItemId: String
14 | /// The title of the bookmark.
15 | public let title: String
16 | /// The time (in seconds) the bookmark is at in the book.
17 | public let time: TimeInterval
18 | /// The time the bookmark was created.
19 | public let createdAt: Date
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/Ping.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Ping.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 9/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | public extension Audiobookshelf.Request {
10 | /// This endpoint is a simple check to see if the server is operating and responding with JSON correctly.
11 | struct Ping: ResourceRequest {
12 | public let path = "ping"
13 |
14 | public init() {}
15 | }
16 | }
17 |
18 | public extension Audiobookshelf.Request.Ping {
19 | struct Response: Codable, Sendable {
20 | /// Will always be `true`.
21 | public let success: Bool
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibraries.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraries.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 9/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | public extension Audiobookshelf.Request {
10 | /// This endpoint retrieves all libraries accessible to the user.
11 | struct GetLibraries: ResourceRequest {
12 | public let path = "api/libraries"
13 |
14 | public init() {}
15 | }
16 | }
17 |
18 | public extension Audiobookshelf.Request.GetLibraries {
19 | struct Response: Codable, Sendable {
20 | /// The requested libraries.
21 | public let libraries: [Library]
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "AudiobookshelfKit",
7 | platforms: [
8 | .macOS(.v13),
9 | .iOS(.v16),
10 | .watchOS(.v9),
11 | .tvOS(.v16),
12 | ],
13 | products: [
14 | .library(
15 | name: "AudiobookshelfKit",
16 | targets: ["AudiobookshelfKit"]
17 | ),
18 | ],
19 | targets: [
20 | .target(
21 | name: "AudiobookshelfKit"),
22 | .testTarget(
23 | name: "AudiobookshelfKitTests",
24 | dependencies: ["AudiobookshelfKit"],
25 | resources: [.process("Resources")]
26 | ),
27 | ]
28 | )
29 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/LoginResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginResponse.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 1712/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct LoginResponse: Codable, Hashable, Sendable {
12 | /// The authenticated user.
13 | public let user: User
14 | /// The ID of the first library in the list the user has access to.
15 | public let userDefaultLibraryId: String
16 | /// The server's settings.
17 | public let serverSettings: ServerSettings
18 | /// The server's installation source.
19 | private let Source: String
20 | /// The server's installation source.
21 | public var source: String { Source }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetAuthor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAuthor.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 29/5/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves an author.
13 | struct GetAuthor: ResourceRequest {
14 | public var path: String { "api/authors/\(id)" }
15 | public var queryItems: [URLQueryItem]? = [
16 | URLQueryItem(name: "include", value: "items"),
17 | ]
18 |
19 | private let id: String
20 |
21 | public init(id: String) {
22 | self.id = id
23 | }
24 | }
25 | }
26 |
27 | public extension Audiobookshelf.Request.GetAuthor {
28 | typealias Response = AuthorExpanded
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetMediaProgress.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetMediaProgress.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 30/7/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves your media progress that is associated with the given library item ID or podcast episode ID.
13 | struct GetMediaProgress: ResourceRequest {
14 | public var path: String { "api/me/progress/\(libraryItemID)" }
15 |
16 | private let libraryItemID: String
17 |
18 | /// - Parameters:
19 | /// - libraryItemID:
20 | public init(libraryItemID: String) {
21 | self.libraryItemID = libraryItemID
22 | }
23 | }
24 | }
25 |
26 | public extension Audiobookshelf.Request.GetMediaProgress {
27 | typealias Response = MediaProgress
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibraryItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryItem.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves a library item.
13 | struct GetLibraryItem: ResourceRequest {
14 | public var path: String { "api/items/\(id)" }
15 |
16 | public var queryItems: [URLQueryItem]? = [
17 | URLQueryItem(name: "expanded", value: true),
18 | URLQueryItem(name: "include", value: "authors,progress"),
19 | ]
20 |
21 | private let id: String
22 |
23 | public init(id: String) {
24 | self.id = id
25 | }
26 | }
27 | }
28 |
29 | public extension Audiobookshelf.Request.GetLibraryItem {
30 | typealias Response = LibraryItemExpanded
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/LibraryFile.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LibraryFile.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct LibraryFile: Codable, Hashable, Sendable {
12 | public let ino: String
13 | public let metadata: Metadata
14 | public let isSupplementary: Bool?
15 | public let addedAt: Date
16 | public let updatedAt: Date
17 | public let fileType: String
18 | }
19 |
20 | public extension LibraryFile {
21 | struct Metadata: Codable, Hashable, Sendable {
22 | public let filename: String
23 | public let ext: String
24 | public let path: String
25 | public let relPath: String
26 | public let size: Int64
27 | public let mtimeMs: Date
28 | public let ctimeMs: Date
29 | public let birthtimeMs: Date
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/RefreshAccessToken.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RefreshAccessToken.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 9/9/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint refreshes an access token using a refresh token.
13 | struct RefreshAccessToken: ResourceRequest {
14 | public let path = "auth/refresh"
15 | public let httpMethod = "POST"
16 | public var headers: [String : String]? {
17 | ["x-refresh-token": refreshToken]
18 | }
19 |
20 | private let refreshToken: String
21 |
22 | public init(refreshToken: String) {
23 | self.refreshToken = refreshToken
24 | }
25 | }
26 | }
27 |
28 | public extension Audiobookshelf.Request.RefreshAccessToken {
29 | typealias Response = LoginResponse
30 | }
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/RefreshAccessTokenTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RefreshAccessTokenTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 9/9/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | @testable import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct RefreshAccessTokenTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.RefreshAccessToken(refreshToken: "test-refresh-token")
16 | .asURLRequest(from: testURL, using: nil, customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("auth/refresh"))
21 |
22 | #expect(data.headers == [
23 | "Accept": "application/json",
24 | "x-refresh-token": "test-refresh-token",
25 | ])
26 |
27 | #expect(data.httpBody == nil)
28 | }
29 | }
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Permissions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Permissions.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Permissions: Codable, Hashable, Sendable {
12 | /// Whether the user can download items to the server.
13 | public let download: Bool
14 | /// Whether the user can update library items.
15 | public let update: Bool
16 | /// Whether the user can delete library items.
17 | public let delete: Bool
18 | /// Whether the user can upload items to the server.
19 | public let upload: Bool
20 | /// Whether the user can access all libraries.
21 | public let accessAllLibraries: Bool
22 | /// Whether the user can access all tags.
23 | public let accessAllTags: Bool
24 | /// Whether the user can access explicit content.
25 | public let accessExplicitContent: Bool
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/LibrarySettings.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LibrarySettings.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct LibrarySettings: Codable, Hashable, Sendable {
12 | /// Whether the library should use square book covers.
13 | /// Must be 0 (for false) or 1 (for true).
14 | public let coverAspectRatio: Int?
15 | /// Whether to disable the folder watcher for the library.
16 | public let disableWatcher: Bool?
17 | /// Whether to skip matching books that already have an ASIN.
18 | public let skipMatchingMediaWithAsin: Bool?
19 | /// Whether to skip matching books that already have an ISBN.
20 | public let skipMatchingMediaWithIsbn: Bool?
21 | /// The cron expression for when to automatically scan the library folders.
22 | /// If null, automatic scanning will be disabled.
23 | public let autoScanCronExpression: String?
24 | }
25 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/PingTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PingTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct PingTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.Ping()
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("ping"))
21 | #expect(data.headers == [
22 | "Accept": "application/json",
23 | "Authorization": "Bearer my-token",
24 | ])
25 | }
26 |
27 | @Test func response() throws {
28 | let data = try JSONEncoder().encode(["success": true])
29 | let response = try Audiobookshelf.Request.Ping.response(from: data)
30 | #expect(response.success == true)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/RemoveBookmarkTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RemoveBookmarkTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct RemoveBookmarkTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.RemoveBookmark(
16 | libraryItemId: "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458",
17 | time: 6446
18 | )
19 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
20 |
21 | let data = RequestData(request: request)
22 |
23 | #expect(data.baseURL == testURL.appendingPathComponent("/api/me/item/8f2b0e4b-d484-47b8-b357-fbdcbc4e6458/bookmark/6446"))
24 | #expect(data.httpMethod == "DELETE")
25 | #expect(data.headers == [
26 | "Accept": "application/json",
27 | "Authorization": "Bearer my-token",
28 | ])
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/Login.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Login.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint logs in a client to the server, returning information about the user and server.
13 | struct Login: ResourceRequest {
14 | public let path = "login"
15 | public let httpMethod = "POST"
16 | public let headers: [String : String]? = ["x-return-tokens": "true"]
17 | public var httpBody: Codable? {
18 | ["username": username, "password": password]
19 | }
20 |
21 | private let username: String
22 | private let password: String
23 |
24 | public init(username: String, password: String) {
25 | self.username = username
26 | self.password = password
27 | }
28 | }
29 | }
30 |
31 | public extension Audiobookshelf.Request.Login {
32 | typealias Response = LoginResponse
33 | }
34 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Collection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Collection.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Collection: Codable, Hashable, Identifiable, Sendable {
12 | /// The ID of the collection.
13 | public let id: String
14 | /// The ID of the library the collection belongs to.
15 | public let libraryId: String
16 | /// The ID of the user that created the collection.
17 | public let userId: String?
18 | /// The name of the collection.
19 | public let name: String
20 | /// The collection's description. Will be null if there is none.
21 | public let description: String?
22 | /// The books that belong to the collection.
23 | public let books: [LibraryItemExpanded]
24 | /// The time (in ms since POSIX epoch) when the collection was last updated.
25 | public let lastUpdate: Date
26 | /// The time (in ms since POSIX epoch) when the collection was created.
27 | public let createdAt: Date
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibraryItemFile.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryItemFile.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | struct GetLibraryItemFile: ResourceRequest {
13 | public var path: String { "api/items/\(id)/file/\(ino)" }
14 | public var accept: String { "audio/*" }
15 |
16 | private let id: String
17 | private let ino: String
18 |
19 | /// - Parameters:
20 | /// - id: The ID of the library item to retrieve the file for
21 | /// - ino: The inode of the file
22 | public init(
23 | id: String,
24 | ino: String
25 | ) {
26 | self.id = id
27 | self.ino = ino
28 | }
29 | }
30 | }
31 |
32 | public extension Audiobookshelf.Request.GetLibraryItemFile {
33 | typealias Response = Data
34 |
35 | static func response(from data: Data) throws -> Data {
36 | data
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Lachlan Charlick
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/AuthorExpanded.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthorExpanded.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct AuthorExpanded: Codable, Hashable, Sendable {
12 | /// The ID of the author.
13 | public let id: String
14 | /// The ASIN of the author. Will be null if unknown.
15 | public let asin: String?
16 | /// The name of the author.
17 | public let name: String
18 | /// A description of the author. Will be null if there is none.
19 | public let description: String?
20 | /// The absolute path for the author image. Will be null if there is no image.
21 | public let imagePath: String?
22 | /// The time (in ms since POSIX epoch) when the author was added.
23 | public let addedAt: Date
24 | /// The time (in ms since POSIX epoch) when the author was last updated.
25 | public let updatedAt: Date
26 | /// The ID of the library the author belongs to.
27 | public let libraryId: String
28 | public let libraryItems: [LibraryItemExpanded]
29 | }
30 |
--------------------------------------------------------------------------------
/Example/Example/Services/Player.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Player.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 24/4/2024.
6 | //
7 |
8 | import AudiobookshelfKit
9 | import AVFoundation
10 | import Foundation
11 | import OSLog
12 |
13 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "player")
14 |
15 | @MainActor @Observable final class Player {
16 | private var player = AVQueuePlayer()
17 |
18 | func play(item: LibraryItemExpanded, serverInfo: ServerInfo) {
19 | let playerItems = item.media.audioFiles.compactMap { audioFile -> AVPlayerItem? in
20 | let request: URLRequest
21 | do {
22 | request = try Audiobookshelf.Request.GetLibraryItemFile(id: item.id, ino: audioFile.ino)
23 | .asURLRequest(from: serverInfo.url, using: serverInfo.token, tokenStrategy: .queryItem)
24 | } catch {
25 | logger.error("Failed to initialize asset for file: \(error)")
26 | return nil
27 | }
28 |
29 | return AVPlayerItem(url: request.url!)
30 | }
31 |
32 | player = AVQueuePlayer(items: playerItems)
33 | player.play()
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/RemoveBookmark.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RemoveBookmark.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint removes a bookmark for the authenticated user.
13 | struct RemoveBookmark: ResourceRequest {
14 | public var path: String { "api/me/item/\(libraryItemId)/bookmark/\(Int(time))" }
15 | public let httpMethod = "DELETE"
16 |
17 | private let libraryItemId: String
18 | private let time: TimeInterval
19 |
20 | /// - Parameters:
21 | /// - libraryItemId: The ID of the library item.
22 | /// - time: The time (in seconds) where the bookmark is.
23 | public init(libraryItemId: String, time: TimeInterval) {
24 | self.libraryItemId = libraryItemId
25 | self.time = time
26 | }
27 | }
28 | }
29 |
30 | public extension Audiobookshelf.Request.RemoveBookmark {
31 | typealias Response = Data
32 |
33 | static func response(from data: Data) throws -> Data {
34 | data
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/AuthMethod.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthMethod.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 15/12/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public enum AuthMethod: Codable, Hashable, Sendable {
12 | case local
13 | case openid
14 | case unknown(String)
15 |
16 | public init(from decoder: Decoder) throws {
17 | let container = try decoder.singleValueContainer()
18 | let stringValue = try container.decode(String.self)
19 |
20 | switch stringValue.lowercased() {
21 | case "local":
22 | self = .local
23 | case "openid":
24 | self = .openid
25 | default:
26 | self = .unknown(stringValue)
27 | }
28 | }
29 |
30 | public func encode(to encoder: Encoder) throws {
31 | var container = encoder.singleValueContainer()
32 | switch self {
33 | case .local:
34 | try container.encode("local")
35 | case .openid:
36 | try container.encode("openid")
37 | case let .unknown(value):
38 | try container.encode(value)
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/OAuth2Callback.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OAuth2Callback.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 16/12/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This API call finalizes the OAuth2 flow.
13 | struct OAuth2Callback: ResourceRequest {
14 | public let path = "auth/openid/callback"
15 |
16 | public var queryItems: [URLQueryItem]? {
17 | [
18 | URLQueryItem(name: "state", value: state),
19 | URLQueryItem(name: "code", value: code),
20 | URLQueryItem(name: "code_verifier", value: codeVerifier),
21 | ]
22 | }
23 |
24 | private let state: String
25 | private let code: String
26 | private let codeVerifier: String
27 |
28 | public init(state: String, code: String, codeVerifier: String) {
29 | self.state = state
30 | self.code = code
31 | self.codeVerifier = codeVerifier
32 | }
33 | }
34 | }
35 |
36 | public extension Audiobookshelf.Request.OAuth2Callback {
37 | typealias Response = LoginResponse
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Author.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Author.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Author: Codable, Hashable, Sendable {
12 | /// The ID of the author.
13 | public let id: String
14 | /// The ASIN of the author. Will be null if unknown.
15 | public let asin: String?
16 | /// The name of the author.
17 | public let name: String
18 | /// A description of the author. Will be null if there is none.
19 | public let description: String?
20 | /// The absolute path for the author image. Will be null if there is no image.
21 | public let imagePath: String?
22 | /// The time (in ms since POSIX epoch) when the author was added.
23 | public let addedAt: Date
24 | /// The time (in ms since POSIX epoch) when the author was last updated.
25 | public let updatedAt: Date
26 | /// The ID of the library the author belongs to.
27 | public let libraryId: String
28 | /// The number of books associated with the author in the library.
29 | public let numBooks: Int
30 | /// The name of the author, last name first.
31 | public let lastFirst: String?
32 | }
33 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/library_authors.json:
--------------------------------------------------------------------------------
1 | {
2 | "results": [
3 | {
4 | "id": "7e2f3c5b-9778-4505-8719-8fdce133600f",
5 | "asin": null,
6 | "name": "Brandon Sanderson",
7 | "description": null,
8 | "imagePath": null,
9 | "addedAt": 1713354162111,
10 | "updatedAt": 1713354162111,
11 | "libraryId": "71288985-7f00-4a29-b671-836edde7d3a4",
12 | "numBooks": 3,
13 | "lastFirst": "Sanderson, Brandon"
14 | },
15 | {
16 | "id": "55550aac-c2f4-4649-a517-e03dce886076",
17 | "asin": null,
18 | "name": "JRR Tolkien",
19 | "description": null,
20 | "imagePath": null,
21 | "addedAt": 1654499251578,
22 | "updatedAt": 1654499251578,
23 | "libraryId": "71288985-7f00-4a29-b671-836edde7d3a4",
24 | "numBooks": 7,
25 | "lastFirst": "Tolkien, JRR"
26 | },
27 | {
28 | "id": "e49320dc-f3f5-4c59-bee8-1b6a39ab4eb4",
29 | "asin": null,
30 | "name": "Robert Jordan",
31 | "description": null,
32 | "imagePath": null,
33 | "addedAt": 1713354158273,
34 | "updatedAt": 1713354158273,
35 | "libraryId": "71288985-7f00-4a29-b671-836edde7d3a4",
36 | "numBooks": 15,
37 | "lastFirst": "Jordan, Robert"
38 | }
39 | ],
40 | "total": 3,
41 | "page": 1,
42 | "limit": 10
43 | }
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Library.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Library.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Library: Codable, Hashable, Identifiable, Sendable {
12 | /// The ID of the library.
13 | public let id: String
14 | /// The name of the library.
15 | public let name: String
16 | /// The folders that the library is composed of on the server.
17 | public let folders: [Folder]
18 | /// Display position of the library in the list of libraries. Must be >= 1.
19 | public let displayOrder: Int
20 | /// The selected icon for the library. See Library Icons for a list of possible icons.
21 | public let icon: String
22 | /// The type of media that the library contains.
23 | public let mediaType: MediaType
24 | /// Preferred metadata provider for the library.
25 | public let provider: String
26 | /// The settings for the library.
27 | public let settings: LibrarySettings
28 | /// The time when the library was created.
29 | public let createdAt: Date
30 | /// The time when the library was last updated.
31 | public let lastUpdate: Date
32 | /// The time when the library was last scanned.
33 | public let lastScan: Date?
34 | }
35 |
--------------------------------------------------------------------------------
/Example/Example/ExampleApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExampleApp.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 9/4/2024.
6 | //
7 |
8 | import AudiobookshelfKit
9 | import SwiftUI
10 |
11 | @main
12 | struct ExampleApp: App {
13 | @State private var router: Router
14 | @State private var player: Player
15 | @State private var serverInfo: ServerInfo
16 |
17 | init() {
18 | router = Router()
19 | player = Player()
20 | serverInfo = ServerInfo()
21 | }
22 |
23 | var body: some Scene {
24 | WindowGroup {
25 | NavigationStack(path: $router.path) {
26 | SignInView()
27 | .navigationDestination(for: Route.self) { route in
28 | switch route {
29 | case .libraries:
30 | LibrariesView()
31 | case let .library(id):
32 | LibraryView(id: id)
33 | case let .book(id, title):
34 | BookDetailView(id: id, title: title)
35 | }
36 | }
37 | }
38 | }
39 | .environment(\.client, Audiobookshelf(sessionConfiguration: .default))
40 | .environment(router)
41 | .environment(player)
42 | .environment(serverInfo)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/Playlist.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Playlist.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Playlist: Codable, Hashable, Identifiable, Sendable {
12 | /// The ID of the playlist.
13 | public let id: String
14 | /// The ID of the library the playlist belongs to.
15 | public let libraryId: String
16 | /// The ID of the user the playlist belongs to.
17 | public let userId: String
18 | /// The name of the playlist.
19 | public let name: String
20 | /// The description of the playlist.
21 | public let description: String?
22 | /// The absolute path on the server of the cover file. Will be null if there is no cover.
23 | public let coverPath: String?
24 | /// The items in the playlist.
25 | public let items: [PlaylistItem]
26 | /// The time (in ms since POSIX epoch) when the playlist was last updated.
27 | public let lastUpdate: Date
28 | /// The time (in ms since POSIX epoch) when the playlist was created.
29 | public let createdAt: Date
30 | }
31 |
32 | public struct PlaylistItem: Codable, Hashable, Sendable {
33 | /// The ID of the library item.
34 | public let libraryItemId: String
35 | /// The ID of the podcast episode.
36 | public let episodeId: String?
37 | /// The library item, expanded.
38 | public let libraryItem: LibraryItemExpanded?
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/CreateBookmark.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CreateBookmark.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint creates a bookmark for the authenticated user.
13 | struct CreateBookmark: ResourceRequest {
14 | public var path: String { "api/me/item/\(libraryItemId)/bookmark" }
15 | public let httpMethod = "POST"
16 | public var httpBody: Codable? {
17 | RequestBody(time: time, title: title)
18 | }
19 |
20 | private let libraryItemId: String
21 | private let time: TimeInterval
22 | private let title: String
23 |
24 | /// - Parameters:
25 | /// - libraryItemId: The ID of the library item.
26 | /// - time: The time (in seconds) in the book to place the bookmark.
27 | /// - title: The title of the bookmark.
28 | public init(libraryItemId: String, time: TimeInterval, title: String) {
29 | self.libraryItemId = libraryItemId
30 | self.time = time
31 | self.title = title
32 | }
33 | }
34 | }
35 |
36 | public extension Audiobookshelf.Request.CreateBookmark {
37 | typealias Response = Bookmark
38 |
39 | struct RequestBody: Codable {
40 | public let time: TimeInterval
41 | public let title: String
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetUserPlaybackSessions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetUserPlaybackSessions.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves a user's playback sessions.
13 | struct GetUserPlaybackSessions: ResourceRequest {
14 | public var path: String { "api/me/listening-sessions" }
15 |
16 | public var queryItems: [URLQueryItem]? {
17 | [
18 | URLQueryItem(name: "page", value: String(page)),
19 | URLQueryItem(name: "itemsPerPage", value: String(itemsPerPage)),
20 | ]
21 | }
22 |
23 | private let page: Int
24 | private let itemsPerPage: Int
25 |
26 | public init(page: Int, itemsPerPage: Int) {
27 | self.page = page
28 | self.itemsPerPage = itemsPerPage
29 | }
30 | }
31 | }
32 |
33 | public extension Audiobookshelf.Request.GetUserPlaybackSessions {
34 | struct Response: Codable, Sendable {
35 | /// The requested sessions.
36 | public let sessions: [PlaybackSession]
37 | /// The total number of sessions.
38 | public let total: Int
39 | /// The number of pages.
40 | public let numPages: Int
41 | /// The provided itemsPerPage parameter.
42 | public let itemsPerPage: Int
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/UpdateBookmark.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UpdateBookmark.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint updates a bookmark for the authenticated user.
13 | struct UpdateBookmark: ResourceRequest {
14 | public var path: String { "api/me/item/\(libraryItemId)/bookmark" }
15 | public let httpMethod = "PATCH"
16 | public var httpBody: Codable? {
17 | RequestBody(time: time, title: title)
18 | }
19 |
20 | private let libraryItemId: String
21 | private let time: TimeInterval
22 | private let title: String
23 |
24 | /// - Parameters:
25 | /// - libraryItemId: The ID of the library item.
26 | /// - time: The time (in seconds) in the book where the bookmark is.
27 | /// - title: The new title of the bookmark.
28 | public init(libraryItemId: String, time: TimeInterval, title: String) {
29 | self.libraryItemId = libraryItemId
30 | self.time = time
31 | self.title = title
32 | }
33 | }
34 | }
35 |
36 | public extension Audiobookshelf.Request.UpdateBookmark {
37 | typealias Response = Bookmark
38 |
39 | struct RequestBody: Codable {
40 | public let time: TimeInterval
41 | public let title: String
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetAuthorImageTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAuthorImageTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 6/5/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetAuthorImageTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetAuthorImage(id: "my-author")
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("api/authors/my-author/image"))
21 | #expect(data.headers == [
22 | "Accept": "image/*",
23 | "Authorization": "Bearer my-token",
24 | ])
25 | }
26 |
27 | @Test func request_optionalParameters() throws {
28 | let request = try Audiobookshelf.Request.GetAuthorImage(
29 | id: "my-author",
30 | width: 100,
31 | height: 200,
32 | format: .jpeg,
33 | raw: true
34 | )
35 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
36 |
37 | let data = RequestData(request: request)
38 |
39 | #expect(data.queryItems == [
40 | "width": "100",
41 | "height": "200",
42 | "format": "jpeg",
43 | "raw": "1",
44 | ])
45 | }
46 | }
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetLibraryItemCoverTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryItemCoverTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetLibraryItemCoverTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetLibraryItemCover(id: "my-item")
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("api/items/my-item/cover"))
21 | #expect(data.headers == [
22 | "Accept": "image/*",
23 | "Authorization": "Bearer my-token",
24 | ])
25 | }
26 |
27 | @Test func request_optionalParameters() throws {
28 | let request = try Audiobookshelf.Request.GetLibraryItemCover(
29 | id: "my-item",
30 | width: 100,
31 | height: 200,
32 | format: .jpeg,
33 | raw: true
34 | )
35 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
36 |
37 | let data = RequestData(request: request)
38 |
39 | #expect(data.queryItems == [
40 | "width": "100",
41 | "height": "200",
42 | "format": "jpeg",
43 | "raw": "1",
44 | ])
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/user.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "a7ea108d-ed8c-4179-bedd-07cf4db866aa",
3 | "oldUserId": "root",
4 | "username": "root",
5 | "email": null,
6 | "type": "root",
7 | "token": "my-token",
8 | "mediaProgress": [
9 | {
10 | "id": "8e7ed07e-19b4-4ccf-9668-03a7ed20f1dd",
11 | "userId": "a7ea108d-ed8c-4179-bedd-07cf4db866aa",
12 | "libraryItemId": "27bec22a-0902-4226-a6ba-677a95fd993d",
13 | "episodeId": null,
14 | "mediaItemId": "66d835f9-5899-41e2-8184-2b235f6ad998",
15 | "mediaItemType": "book",
16 | "duration": 102531.286,
17 | "progress": 0.00006908974757226784,
18 | "currentTime": 7.083860668,
19 | "isFinished": false,
20 | "hideFromContinueListening": false,
21 | "lastUpdate": 1660451502259,
22 | "startedAt": 1660451494311,
23 | "finishedAt": null
24 | }
25 | ],
26 | "seriesHideFromContinueListening": [],
27 | "bookmarks": [
28 | {
29 | "libraryItemId": "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458",
30 | "title": "1234",
31 | "time": 6446,
32 | "createdAt": 1723386963225
33 | }
34 | ],
35 | "isActive": true,
36 | "isLocked": false,
37 | "lastSeen": 1712674531253,
38 | "createdAt": 1650709131028,
39 | "permissions": {
40 | "download": true,
41 | "update": true,
42 | "delete": true,
43 | "upload": true,
44 | "accessAllLibraries": true,
45 | "accessAllTags": true,
46 | "accessExplicitContent": true
47 | },
48 | "librariesAccessible": [],
49 | "hasOpenIDLink": false
50 | }
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetLibraryItemFileTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryItemFileTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetLibraryItemFileTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetLibraryItemFile(id: "my-item", ino: "my-inode")
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("api/items/my-item/file/my-inode"))
21 | #expect(data.headers == [
22 | "Accept": "audio/*",
23 | "Authorization": "Bearer my-token",
24 | ])
25 | #expect(data.queryItems == [:])
26 | }
27 |
28 | @Test func request_queryItemTokenStrategy() throws {
29 | let request = try Audiobookshelf.Request.GetLibraryItemFile(id: "my-item", ino: "my-inode")
30 | .asURLRequest(from: testURL, using: "my-token", tokenStrategy: .queryItem, customHeaders: [:])
31 |
32 | let data = RequestData(request: request)
33 |
34 | #expect(data.baseURL == testURL.appendingPathComponent("api/items/my-item/file/my-inode"))
35 | #expect(data.headers == [
36 | "Accept": "audio/*",
37 | ])
38 | #expect(data.queryItems == ["token": "my-token"])
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/libraries.json:
--------------------------------------------------------------------------------
1 | {
2 | "libraries": [
3 | {
4 | "id": "lib_5yvub9dqvctlcrza6h",
5 | "name": "Main",
6 | "folders": [
7 | {
8 | "id": "audiobooks",
9 | "fullPath": "/audiobooks",
10 | "libraryId": "main",
11 | "addedAt": 1633522963509
12 | }
13 | ],
14 | "displayOrder": 1,
15 | "icon": "audiobookshelf",
16 | "mediaType": "book",
17 | "provider": "audible",
18 | "settings": {
19 | "coverAspectRatio": 1,
20 | "disableWatcher": false,
21 | "skipMatchingMediaWithAsin": false,
22 | "skipMatchingMediaWithIsbn": false,
23 | "autoScanCronExpression": null
24 | },
25 | "createdAt": 1633522963509,
26 | "lastUpdate": 1646520916818
27 | },
28 | {
29 | "id": "lib_c1u6t4p45c35rf0nzd",
30 | "name": "Podcasts",
31 | "folders": [
32 | {
33 | "id": "fol_bev1zuxhb0j0s1wehr",
34 | "fullPath": "/podcasts",
35 | "libraryId": "lib_c1u6t4p45c35rf0nzd",
36 | "addedAt": 1650462940610
37 | }
38 | ],
39 | "displayOrder": 4,
40 | "icon": "database",
41 | "mediaType": "podcast",
42 | "provider": "itunes",
43 | "settings": {
44 | "coverAspectRatio": 1,
45 | "disableWatcher": false,
46 | "skipMatchingMediaWithAsin": false,
47 | "skipMatchingMediaWithIsbn": false,
48 | "autoScanCronExpression": null
49 | },
50 | "createdAt": 1650462940610,
51 | "lastUpdate": 1650462940610
52 | }
53 | ]
54 | }
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibraryPlaylists.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryPlaylists.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves all playlists for a library that are accessible to the user.
13 | struct GetLibraryPlaylists: ResourceRequest {
14 | public var path: String { "api/libraries/\(libraryID)/playlists" }
15 |
16 | public var queryItems: [URLQueryItem]? {
17 | var items = [URLQueryItem]()
18 | items.append(URLQueryItem(name: "limit", value: String(limit)))
19 | items.append(URLQueryItem(name: "page", value: String(page)))
20 | return items
21 | }
22 |
23 | private let libraryID: String
24 | private let limit: Int
25 | private let page: Int
26 |
27 | /// - Parameters:
28 | /// - libraryID: The ID of the library.
29 | /// - limit: Limit the number of returned results per page. If 0, no limit will be applied.
30 | /// - page: The page number (0 indexed) to request. If there is no limit applied, then page will have no effect and all results will be returned.
31 | public init(
32 | libraryID: String,
33 | limit: Int = 0,
34 | page: Int = 0
35 | ) {
36 | self.libraryID = libraryID
37 | self.limit = limit
38 | self.page = page
39 | }
40 | }
41 | }
42 |
43 | public extension Audiobookshelf.Request.GetLibraryPlaylists {
44 | struct Response: Codable, Sendable {
45 | public let results: [Playlist]
46 | public let total: Int
47 | public let limit: Int
48 | public let page: Int
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Example/Example/Views/LibrariesView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LibrariesView.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 12/4/2024.
6 | //
7 |
8 | import AudiobookshelfKit
9 | import SwiftUI
10 |
11 | struct LibrariesView: View {
12 | @Environment(\.client) private var client
13 | @Environment(ServerInfo.self) private var serverInfo
14 | @Environment(\.isPreview) private var isPreview
15 |
16 | @State private var libraries: [Library]?
17 | @State private var errorMessage: String?
18 |
19 | private func getLibraries() async {
20 | let request = Audiobookshelf.Request.GetLibraries()
21 | let result = await client.request(request, from: serverInfo.url, token: serverInfo.token)
22 | switch result {
23 | case let .success(response):
24 | libraries = response.libraries
25 | case let .failure(error):
26 | errorMessage = error.description
27 | }
28 | }
29 |
30 | var body: some View {
31 | Group {
32 | if let errorMessage {
33 | Text(errorMessage)
34 | .foregroundStyle(.red)
35 | }
36 | if let libraries {
37 | List {
38 | ForEach(libraries) { library in
39 | NavigationLink(value: Route.library(id: library.id)) {
40 | Text(library.name)
41 | }
42 | }
43 | }
44 | } else {
45 | ProgressView()
46 | }
47 | }
48 | .task {
49 | if !isPreview {
50 | await getLibraries()
51 | }
52 | }
53 | .navigationBarTitleDisplayMode(.inline)
54 | .navigationTitle("Libraries")
55 | }
56 | }
57 |
58 | #Preview {
59 | LibrariesView()
60 | .environment(ServerInfo.mock)
61 | .environment(\.isPreview, true)
62 | }
63 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/StatusTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatusTests.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 15/12/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct StatusTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.Status()
16 | .asURLRequest(from: testURL, using: nil, customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("status"))
21 | #expect(data.headers == [
22 | "Accept": "application/json",
23 | ])
24 | }
25 |
26 | @Test func response() throws {
27 | let data = """
28 | {
29 | "app": "audiobookshelf",
30 | "serverVersion": "2.17.3",
31 | "isInit": true,
32 | "language": "en-us",
33 | "authMethods": [
34 | "local",
35 | "openid",
36 | "woof"
37 | ],
38 | "authFormData": {
39 | "authLoginCustomMessage": "A custom message",
40 | "authOpenIDButtonText": "Login with OpenId",
41 | "authOpenIDAutoLaunch": false
42 | }
43 | }
44 | """.data(using: .utf8)
45 |
46 | let response = try Audiobookshelf.Request.Status.response(from: data!)
47 |
48 | #expect(response.app == "audiobookshelf")
49 | #expect(response.serverVersion == "2.17.3")
50 | #expect(response.isInit)
51 | #expect(response.language == "en-us")
52 | #expect(response.authMethods == [.local, .openid, .unknown("woof")])
53 | #expect(response.authFormData.authLoginCustomMessage == "A custom message")
54 | #expect(response.authFormData.authOpenIDButtonText == "Login with OpenId")
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/CreateBookmarkTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CreateBookmarkTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct CreateBookmarkTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.CreateBookmark(
16 | libraryItemId: "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458",
17 | time: 6446,
18 | title: "Chapter 1 - The Beginning"
19 | )
20 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
21 |
22 | let data = RequestData(request: request)
23 |
24 | #expect(data.baseURL == testURL.appendingPathComponent("/api/me/item/8f2b0e4b-d484-47b8-b357-fbdcbc4e6458/bookmark"))
25 | #expect(data.httpMethod == "POST")
26 | #expect(data.headers == [
27 | "Accept": "application/json",
28 | "Authorization": "Bearer my-token",
29 | "Content-Type": "application/json",
30 | ])
31 |
32 | let body = try JSONDecoder().decode(
33 | Audiobookshelf.Request.CreateBookmark.RequestBody.self,
34 | from: data.rawHttpBody!
35 | )
36 | #expect(body.time == 6446)
37 | #expect(body.title == "Chapter 1 - The Beginning")
38 | }
39 |
40 | @Test func response() throws {
41 | let response = try loadResponse(
42 | "create_bookmark",
43 | for: Audiobookshelf.Request.CreateBookmark.self
44 | )
45 |
46 | #expect(response.libraryItemId == "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458")
47 | #expect(response.title == "Hello, world!")
48 | #expect(response.time == 6446)
49 | #expect(response.createdAt == Date(timeIntervalSince1970: 1_723_386_963_225 / 1000))
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/UpdateBookmarkTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UpdateBookmarkTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct UpdateBookmarkTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.UpdateBookmark(
16 | libraryItemId: "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458",
17 | time: 6446,
18 | title: "Chapter 1 - Updated Title"
19 | )
20 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
21 |
22 | let data = RequestData(request: request)
23 |
24 | #expect(data.baseURL == testURL.appendingPathComponent("/api/me/item/8f2b0e4b-d484-47b8-b357-fbdcbc4e6458/bookmark"))
25 | #expect(data.httpMethod == "PATCH")
26 | #expect(data.headers == [
27 | "Accept": "application/json",
28 | "Authorization": "Bearer my-token",
29 | "Content-Type": "application/json",
30 | ])
31 |
32 | let body = try JSONDecoder().decode(
33 | Audiobookshelf.Request.UpdateBookmark.RequestBody.self,
34 | from: data.rawHttpBody!
35 | )
36 | #expect(body.time == 6446)
37 | #expect(body.title == "Chapter 1 - Updated Title")
38 | }
39 |
40 | @Test func response() throws {
41 | let response = try loadResponse(
42 | "update_bookmark",
43 | for: Audiobookshelf.Request.UpdateBookmark.self
44 | )
45 |
46 | #expect(response.libraryItemId == "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458")
47 | #expect(response.title == "Hello, world!!")
48 | #expect(response.time == 6446)
49 | #expect(response.createdAt == Date(timeIntervalSince1970: 1_723_386_963_225 / 1000))
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetLibraryAuthorsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryAuthorsTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 28/5/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetLibraryAuthorsTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetLibraryAuthors(libraryID: "my-library", limit: 10, page: 1)
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("api/libraries/my-library/authors"))
21 | #expect(data.headers == [
22 | "Accept": "application/json",
23 | "Authorization": "Bearer my-token",
24 | ])
25 | #expect(data.queryItems == [
26 | "limit": "10",
27 | "page": "1",
28 | ])
29 | }
30 |
31 | @Test func response() throws {
32 | let response = try loadResponse(
33 | "library_authors",
34 | for: Audiobookshelf.Request.GetLibraryAuthors.self
35 | )
36 |
37 | #expect(response.results.count == 3)
38 | let author = response.results[0]
39 | #expect(author.id == "7e2f3c5b-9778-4505-8719-8fdce133600f")
40 | #expect(author.name == "Brandon Sanderson")
41 | #expect(author.description == nil)
42 | #expect(author.imagePath == nil)
43 | #expect(author.addedAt == Date(timeIntervalSince1970: 1_713_354_162_111 / 1000))
44 | #expect(author.updatedAt == Date(timeIntervalSince1970: 1_713_354_162_111 / 1000))
45 | #expect(author.libraryId == "71288985-7f00-4a29-b671-836edde7d3a4")
46 | #expect(author.numBooks == 3)
47 | #expect(author.lastFirst == "Sanderson, Brandon")
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/OAuth2CallbackTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OAuth2CallbackTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct OAuth2CallbackTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.OAuth2Callback(
16 | state: "random-state-string",
17 | code: "authorization-code-from-provider",
18 | codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
19 | )
20 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
21 |
22 | let data = RequestData(request: request)
23 |
24 | #expect(data.baseURL == testURL.appendingPathComponent("/auth/openid/callback"))
25 | #expect(data.headers == [
26 | "Accept": "application/json",
27 | "Authorization": "Bearer my-token",
28 | ])
29 |
30 | #expect(data.queryItems == [
31 | "state": "random-state-string",
32 | "code": "authorization-code-from-provider",
33 | "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
34 | ])
35 | }
36 |
37 | @Test func response() throws {
38 | // OAuth2Callback returns the same response as Login
39 | let response = try loadResponse(
40 | "login",
41 | for: Audiobookshelf.Request.OAuth2Callback.self
42 | )
43 |
44 | #expect(response.user.id == "a7ea108d-ed8c-4179-bedd-07cf4db866aa")
45 | #expect(response.user.username == "root")
46 | #expect(response.user.type == .root)
47 | #expect(response.user.token == "my-token")
48 | #expect(response.userDefaultLibraryId == "5d8f9658-d52a-44f7-b52a-f7f02480f117")
49 | #expect(response.serverSettings.id == "server-settings")
50 | #expect(response.source == "docker")
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Example/Example/Views/Misc/Cover.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Cover.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 18/4/2024.
6 | //
7 |
8 | import AudiobookshelfKit
9 | import Foundation
10 | import SwiftUI
11 |
12 | struct Cover: View {
13 | let itemID: String
14 | var size: CGFloat = 80
15 |
16 | @Environment(\.client) private var client
17 | @Environment(ServerInfo.self) private var serverInfo
18 | @Environment(\.isPreview) private var isPreview
19 | @Environment(\.displayScale) private var displayScale
20 |
21 | @State private var image: UIImage?
22 |
23 | private enum Constants {
24 | static let cornerRadius: CGFloat = 8
25 | }
26 |
27 | private func getCover() async {
28 | let request = Audiobookshelf.Request.GetLibraryItemCover(
29 | id: itemID,
30 | width: Int(size * displayScale),
31 | height: Int(size * displayScale),
32 | format: .webp
33 | )
34 | let result = await client.request(request, from: serverInfo.url, token: serverInfo.token)
35 | switch result {
36 | case let .success(data):
37 | image = UIImage(data: data)
38 | case .failure:
39 | break
40 | }
41 | }
42 |
43 | var body: some View {
44 | if let image {
45 | Image(uiImage: image)
46 | .resizable()
47 | .aspectRatio(1, contentMode: .fit)
48 | .frame(height: size)
49 | .clipShape(RoundedRectangle(cornerRadius: Constants.cornerRadius, style: .continuous))
50 | } else {
51 | RoundedRectangle(cornerRadius: Constants.cornerRadius, style: .continuous)
52 | .foregroundStyle(.secondary)
53 | .aspectRatio(1, contentMode: .fit)
54 | .frame(height: size)
55 | .task {
56 | if !isPreview {
57 | await getCover()
58 | }
59 | }
60 | }
61 | }
62 | }
63 |
64 | #Preview {
65 | Cover(itemID: "my-item")
66 | .environment(ServerInfo.mock)
67 | }
68 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Util.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Util.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 29/5/20.
6 | // Copyright © 2020 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class BundleLocator {}
12 |
13 | func loadResource(_ name: String, ext: String) throws -> Data {
14 | guard let path = Bundle.module.path(forResource: name, ofType: ext) else {
15 | throw ResourceNotFoundError(name: name, ext: ext)
16 | }
17 | return try Data(contentsOf: URL(fileURLWithPath: path))
18 | }
19 |
20 | func urlForFile(_ name: String, ext: String) -> URL {
21 | let bundle = Bundle(for: BundleLocator.self)
22 | return bundle.url(forResource: name, withExtension: ext)!
23 | }
24 |
25 | struct ResourceNotFoundError: Error {
26 | let name: String
27 | let ext: String
28 | }
29 |
30 | struct RequestData {
31 | init(request: URLRequest) {
32 | let url = request.url!
33 | baseURL = url.removingQueryItems()
34 | httpMethod = request.httpMethod
35 | headers = request.allHTTPHeaderFields
36 |
37 | let items = url.queryItems?.map {
38 | ($0.name, $0.value)
39 | } ?? []
40 |
41 | queryItems = Dictionary(uniqueKeysWithValues: items)
42 |
43 | rawHttpBody = request.httpBody
44 | if let httpBody = request.httpBody {
45 | self.httpBody = try? JSONDecoder().decode([String: String].self, from: httpBody)
46 | } else {
47 | httpBody = nil
48 | }
49 | }
50 |
51 | let baseURL: URL
52 | let httpMethod: String?
53 | let queryItems: [String: String?]
54 | let headers: [String: String]?
55 | let rawHttpBody: Data?
56 | let httpBody: [String: String]?
57 | }
58 |
59 | private extension URL {
60 | var queryItems: [URLQueryItem]? {
61 | URLComponents(url: self, resolvingAgainstBaseURL: true)?.queryItems
62 | }
63 |
64 | func removingQueryItems() -> URL {
65 | var comps = URLComponents(url: self, resolvingAgainstBaseURL: true)!
66 | comps.queryItems = nil
67 | return comps.url!
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/MediaProgress.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MediaProgress.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct MediaProgress: Codable, Hashable, Identifiable, Sendable {
12 | /// The ID of the media progress.
13 | /// If the media progress is for a book, this will just be the libraryItemId.
14 | /// If for a podcast episode, it will be a hyphenated combination of the libraryItemId and episodeId.
15 | public let id: String
16 | /// The ID of the user the media progress is for.
17 | public let userId: String
18 | /// The ID of the library item the media progress is of.
19 | public let libraryItemId: String
20 | /// The ID of the podcast episode the media progress is of.
21 | /// Will be nil if the progress is for a book.
22 | public let episodeId: String?
23 | ///
24 | public let mediaItemId: String
25 | ///
26 | public let mediaItemType: MediaType
27 | /// The total duration (in seconds) of the media.
28 | /// Will be 0 if the media was marked as finished without the user listening to it.
29 | public let duration: TimeInterval?
30 | /// The percentage completion progress of the media.
31 | /// Will be 1 if the media is finished.
32 | public let progress: Double
33 | /// The current time (in seconds) of the user's progress.
34 | /// If the media has been marked as finished, this will be the time the user was at beforehand.
35 | public let currentTime: TimeInterval
36 | /// Whether the media is finished.
37 | public let isFinished: Bool
38 | /// Whether the media will be hidden from the "Continue Listening" shelf.
39 | public let hideFromContinueListening: Bool
40 | /// The time when the media progress was last updated.
41 | public let lastUpdate: Date
42 | /// The time when the media progress was created.
43 | public let startedAt: Date
44 | /// The time when the media was finished.
45 | /// Will be nil if the media is not finished.
46 | public let finishedAt: Date?
47 | }
48 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetLibraryCollectionsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryCollectionsTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetLibraryCollectionsTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetLibraryCollections(
16 | libraryID: "my-library",
17 | page: 1,
18 | limit: 10,
19 | sort: "name",
20 | desc: true,
21 | minified: true,
22 | include: "rssfeed"
23 | )
24 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
25 |
26 | let data = RequestData(request: request)
27 |
28 | #expect(data.baseURL == testURL.appendingPathComponent("/api/libraries/my-library/collections"))
29 | #expect(data.headers == [
30 | "Accept": "application/json",
31 | "Authorization": "Bearer my-token",
32 | ])
33 | #expect(data.queryItems == [
34 | "limit": "10",
35 | "page": "1",
36 | "sort": "name",
37 | "desc": "1",
38 | "minified": "1",
39 | "include": "rssfeed",
40 | ])
41 | }
42 |
43 | @Test func request_optionalParameters() throws {
44 | let request = try Audiobookshelf.Request.GetLibraryCollections(
45 | libraryID: "my-library",
46 | page: 0,
47 | limit: 0
48 | )
49 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
50 |
51 | let data = RequestData(request: request)
52 |
53 | #expect(data.baseURL == testURL.appendingPathComponent("/api/libraries/my-library/collections"))
54 | #expect(data.headers == [
55 | "Accept": "application/json",
56 | "Authorization": "Bearer my-token",
57 | ])
58 | #expect(data.queryItems == [
59 | "limit": "0",
60 | "page": "0",
61 | ])
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetMediaProgressTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetMediaProgressTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetMediaProgressTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetMediaProgress(
16 | libraryItemID: "27bec22a-0902-4226-a6ba-677a95fd993d"
17 | )
18 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
19 |
20 | let data = RequestData(request: request)
21 |
22 | #expect(data.baseURL == testURL.appendingPathComponent("/api/me/progress/27bec22a-0902-4226-a6ba-677a95fd993d"))
23 | #expect(data.headers == [
24 | "Accept": "application/json",
25 | "Authorization": "Bearer my-token",
26 | ])
27 |
28 | #expect(data.queryItems.isEmpty)
29 | }
30 |
31 | @Test func response() throws {
32 | let response = try loadResponse(
33 | "media_progress",
34 | for: Audiobookshelf.Request.GetMediaProgress.self
35 | )
36 |
37 | #expect(response.id == "8e7ed07e-19b4-4ccf-9668-03a7ed20f1dd")
38 | #expect(response.userId == "a7ea108d-ed8c-4179-bedd-07cf4db866aa")
39 | #expect(response.libraryItemId == "27bec22a-0902-4226-a6ba-677a95fd993d")
40 | #expect(response.episodeId == nil)
41 | #expect(response.mediaItemId == "66d835f9-5899-41e2-8184-2b235f6ad998")
42 | #expect(response.mediaItemType == .book)
43 | #expect(response.duration == 102_531.286)
44 | #expect(response.progress == 0.00006908974757226784)
45 | #expect(response.currentTime == 7.083860668)
46 | #expect(response.isFinished == false)
47 | #expect(response.hideFromContinueListening == false)
48 | #expect(response.lastUpdate == Date(timeIntervalSince1970: 1_660_451_502_259 / 1000))
49 | #expect(response.startedAt == Date(timeIntervalSince1970: 1_660_451_494_311 / 1000))
50 | #expect(response.finishedAt == nil)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/BatchUpdateMediaProgressTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BatchUpdateMediaProgressTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 3/8/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct BatchUpdateMediaProgressTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.BatchUpdateMediaProgress([])
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("api/me/progress/batch/update"))
21 | #expect(data.httpMethod == "PATCH")
22 | #expect(data.headers == [
23 | "Accept": "application/json",
24 | "Authorization": "Bearer my-token",
25 | "Content-Type": "application/json",
26 | ])
27 |
28 | let httpBody = try JSONDecoder().decode([Audiobookshelf.Request.BatchUpdateMediaProgress.Parameters].self, from: data.rawHttpBody!)
29 |
30 | #expect(httpBody == [])
31 | }
32 |
33 | @Test func requestWithParameters() throws {
34 | let parameters = Audiobookshelf.Request.BatchUpdateMediaProgress.Parameters(
35 | libraryItemId: "123",
36 | episodeId: "456",
37 | duration: 123,
38 | progress: 0.5,
39 | currentTime: 60,
40 | isFinished: false,
41 | hideFromContinueListening: false,
42 | finishedAt: Date(),
43 | startedAt: Date()
44 | )
45 | let request = try Audiobookshelf.Request.BatchUpdateMediaProgress([parameters])
46 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
47 |
48 | let data = RequestData(request: request)
49 |
50 | #expect(data.baseURL == testURL.appendingPathComponent("api/me/progress/batch/update"))
51 | #expect(data.headers == [
52 | "Accept": "application/json",
53 | "Authorization": "Bearer my-token",
54 | "Content-Type": "application/json",
55 | ])
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/OAuth2AuthorizationRequest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OAuth2AuthorizationRequest.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 16/12/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint starts a standard OAuth2 flow with PKCE (required - S256; see RFC7636), to log the user in using SSO.
13 | struct OAuth2AuthorizationRequest: ResourceRequest {
14 | public let path = "auth/openid"
15 | public let accept = "*/*"
16 |
17 | public var queryItems: [URLQueryItem]? {
18 | [
19 | URLQueryItem(name: "code_challenge", value: codeChallenge),
20 | URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod),
21 | URLQueryItem(name: "response_type", value: responseType),
22 | URLQueryItem(name: "redirect_uri", value: redirectURI.absoluteString),
23 | URLQueryItem(name: "client_id", value: clientID),
24 | URLQueryItem(name: "state", value: state),
25 | ]
26 | }
27 |
28 | private let codeChallenge: String
29 | private let codeChallengeMethod: String
30 | private let responseType: String
31 | private let redirectURI: URL
32 | private let clientID: String
33 | private let state: String
34 |
35 | public init(
36 | codeChallenge: String,
37 | codeChallengeMethod: String = "S256",
38 | responseType: String = "code",
39 | redirectURI: URL,
40 | clientID: String,
41 | state: String
42 | ) {
43 | self.codeChallenge = codeChallenge
44 | self.codeChallengeMethod = codeChallengeMethod
45 | self.responseType = responseType
46 | self.redirectURI = redirectURI
47 | self.clientID = clientID
48 | self.state = state
49 | }
50 | }
51 | }
52 |
53 | public extension Audiobookshelf.Request.OAuth2AuthorizationRequest {
54 | typealias Response = Data
55 |
56 | static func response(from data: Data) throws -> Data {
57 | data
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/Status.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Status.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 15/12/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint reports the server's initialization status.
13 | struct Status: ResourceRequest {
14 | public let path = "status"
15 |
16 | public init() {}
17 | }
18 | }
19 |
20 | public extension Audiobookshelf.Request.Status {
21 | struct Response: Codable, Sendable {
22 | public let app: String
23 | public let serverVersion: String
24 | public let isInit: Bool
25 | public let language: String
26 | public let authMethods: Set
27 | public let authFormData: AuthFormData
28 |
29 | #if DEBUG
30 | public init(
31 | app: String,
32 | serverVersion: String,
33 | isInit: Bool,
34 | language: String,
35 | authMethods: Set,
36 | authFormData: Audiobookshelf.Request.Status.Response.AuthFormData
37 | ) {
38 | self.app = app
39 | self.serverVersion = serverVersion
40 | self.isInit = isInit
41 | self.language = language
42 | self.authMethods = authMethods
43 | self.authFormData = authFormData
44 | }
45 | #endif
46 |
47 | public struct AuthFormData: Codable, Sendable {
48 | public let authLoginCustomMessage: String?
49 | public let authOpenIDButtonText: String?
50 | public let authOpenIDAutoLaunch: Bool?
51 |
52 | #if DEBUG
53 | public init(
54 | authLoginCustomMessage: String? = nil,
55 | authOpenIDButtonText: String? = nil,
56 | authOpenIDAutoLaunch: Bool? = nil
57 | ) {
58 | self.authLoginCustomMessage = authLoginCustomMessage
59 | self.authOpenIDButtonText = authOpenIDButtonText
60 | self.authOpenIDAutoLaunch = authOpenIDAutoLaunch
61 | }
62 | #endif
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/User.swift:
--------------------------------------------------------------------------------
1 | //
2 | // User.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct User: Codable, Hashable, Identifiable, Sendable {
12 | /// The ID of the user. Only the root user has the root ID.
13 | public let id: String
14 | ///
15 | public let oldUserId: String?
16 | /// The username of the user.
17 | public let username: String
18 | /// The email of the user.
19 | public let email: String?
20 | /// The type of the user. There will be only one root user which is created when the server first starts.
21 | public let type: UserType
22 | /// The authentication token of the user. Old method of authentication, replaced by access and refresh tokens in ABS 2.26.0+.
23 | public let token: String
24 | /// Long-lived token used to create new access tokens. ABS 2.26.0+.
25 | public let refreshToken: String?
26 | /// Short-lived tokens used for requests. ABS 2.26.0+.
27 | public let accessToken: String?
28 | /// The user's media progress.
29 | public let mediaProgress: [MediaProgress]
30 | /// The IDs of series to hide from the user's "Continue Series" shelf.
31 | public let seriesHideFromContinueListening: [String]
32 | /// The user's bookmarks.
33 | public let bookmarks: [Bookmark]
34 | /// Whether the user's account is active.
35 | public let isActive: Bool
36 | /// Whether the user is locked.
37 | public let isLocked: Bool
38 | /// The time (in ms since POSIX epoch) when the user was last seen by the server. Will be null if the user has never logged in.
39 | public let lastSeen: Date?
40 | /// The time (in ms since POSIX epoch) when the user was created.
41 | public let createdAt: Date
42 | /// The user's permissions.
43 | public let permissions: Permissions
44 | /// The IDs of libraries accessible to the user. An empty array means all libraries are accessible.
45 | public let librariesAccessible: [String]
46 | /// The tags accessible to the user. An empty array means all tags are accessible.
47 | public let itemTagsAccessible: [String]?
48 | }
49 |
50 | public enum UserType: String, Codable, Sendable {
51 | case root
52 | case guest
53 | case user
54 | case admin
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibraryAuthors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryAuthors.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 28/5/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint returns a library's authors.
13 | struct GetLibraryAuthors: ResourceRequest {
14 | public var path: String { "api/libraries/\(libraryID)/authors" }
15 |
16 | public var queryItems: [URLQueryItem]? {
17 | var items = [URLQueryItem]()
18 |
19 | if let limit {
20 | items.append(URLQueryItem(name: "limit", value: String(limit)))
21 | }
22 | if let page {
23 | items.append(URLQueryItem(name: "page", value: String(page)))
24 | }
25 | if let order {
26 | items.append(URLQueryItem(name: "sort", value: order))
27 | }
28 | if let desc, desc {
29 | items.append(URLQueryItem(name: "desc", value: true))
30 | }
31 |
32 | return items.isEmpty ? nil : items
33 | }
34 |
35 | private let libraryID: String
36 | private let limit: Int?
37 | private let page: Int?
38 | private let order: String?
39 | private let desc: Bool?
40 |
41 | /// - Parameters:
42 | /// - libraryID:
43 | /// - limit: Limit the number of returned results per page. If nil, no limit will be applied.
44 | /// - page: The page number (0 indexed) to request. If there is no limit applied, then page will have no effect and all results will be returned.
45 | /// - order:
46 | public init(
47 | libraryID: String,
48 | limit: Int,
49 | page: Int,
50 | order: String? = nil,
51 | desc: Bool? = nil
52 | ) {
53 | self.libraryID = libraryID
54 | self.limit = limit
55 | self.page = page
56 | self.order = order
57 | self.desc = desc
58 | }
59 | }
60 | }
61 |
62 | public extension Audiobookshelf.Request.GetLibraryAuthors {
63 | struct Response: Codable, Sendable {
64 | public let results: [Author]
65 | public let total: Int
66 | public let limit: Int
67 | public let page: Int
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Example/Example/Views/LibraryView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LibraryView.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 17/4/2024.
6 | //
7 |
8 | import AudiobookshelfKit
9 | import SwiftUI
10 |
11 | struct LibraryView: View {
12 | let id: String
13 |
14 | @Environment(\.client) private var client
15 | @Environment(ServerInfo.self) private var serverInfo
16 | @Environment(\.isPreview) private var isPreview
17 |
18 | @State private var items: [AudiobookshelfKit.LibraryItem]
19 | @State private var errorMessage: String?
20 |
21 | init(id: String) {
22 | self.id = id
23 | items = []
24 | }
25 |
26 | init(id: String, items: [AudiobookshelfKit.LibraryItem]) {
27 | self.id = id
28 | self.items = items
29 | }
30 |
31 | private func getLibraryItems() async {
32 | let request = Audiobookshelf.Request.GetLibraryItems(libraryID: id, limit: 10, page: 1)
33 | let result = await client.request(request, from: serverInfo.url, token: serverInfo.token)
34 | switch result {
35 | case let .success(response):
36 | items = response.results
37 | case let .failure(error):
38 | errorMessage = error.description
39 | }
40 | }
41 |
42 | var body: some View {
43 | List {
44 | if let errorMessage {
45 | Text(errorMessage)
46 | .foregroundStyle(.red)
47 | } else {
48 | ForEach(items) { item in
49 | NavigationLink(value: Route.book(id: item.id, title: item.media.metadata.title)) {
50 | HStack {
51 | Cover(itemID: item.id)
52 | Text(item.media.metadata.title)
53 | }
54 | }
55 | }
56 | }
57 | }
58 | .listStyle(.plain)
59 | .task {
60 | if !isPreview {
61 | await getLibraryItems()
62 | }
63 | }
64 | .navigationBarTitleDisplayMode(.inline)
65 | .navigationTitle("Library")
66 | }
67 | }
68 |
69 | #Preview {
70 | let data = try! Data(contentsOf: Bundle.main.url(forResource: "library_items", withExtension: "json")!)
71 | let items = try! JSONDecoder().decode([AudiobookshelfKit.LibraryItem].self, from: data)
72 | return LibraryView(id: "my-library", items: items)
73 | .environment(ServerInfo.mock)
74 | .environment(\.isPreview, true)
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetAuthorImage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAuthorImage.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 6/5/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves an author's image.
13 | struct GetAuthorImage: ResourceRequest {
14 | public var path: String { "api/authors/\(id)/image" }
15 | public var accept: String { "image/*" }
16 |
17 | public var queryItems: [URLQueryItem]? {
18 | var items = [URLQueryItem]()
19 | if let width {
20 | items.append(.init(name: "width", value: String(width)))
21 | }
22 | if let height {
23 | items.append(.init(name: "height", value: String(height)))
24 | }
25 | if let format {
26 | items.append(.init(name: "format", value: format.rawValue))
27 | }
28 | if raw {
29 | items.append(.init(name: "raw", value: "1"))
30 | }
31 | return items
32 | }
33 |
34 | private let id: String
35 | private let width: Int?
36 | private let height: Int?
37 | private let format: Format?
38 | private let raw: Bool
39 |
40 | /// Create a new request to retrieve an author's image.
41 | /// - Parameters:
42 | /// - id: The ID of the author to retrieve the image for
43 | /// - width: The requested width of the author image
44 | /// - height: The requested height of the author image
45 | /// - format: The requested format of the author image
46 | /// - raw: Whether to get the raw author image file instead of a scaled version
47 | public init(
48 | id: String,
49 | width: Int? = nil,
50 | height: Int? = nil,
51 | format: Format? = nil,
52 | raw: Bool = false
53 | ) {
54 | self.id = id
55 | self.width = width
56 | self.height = height
57 | self.format = format
58 | self.raw = raw
59 | }
60 |
61 | public enum Format: String, Sendable {
62 | case webp, jpeg
63 | }
64 | }
65 | }
66 |
67 | public extension Audiobookshelf.Request.GetAuthorImage {
68 | typealias Response = Data
69 |
70 | static func response(from data: Data) throws -> Data {
71 | data
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 | /.claude
92 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibraryItemCover.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryItemCover.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves a library item's cover.
13 | struct GetLibraryItemCover: ResourceRequest {
14 | public var path: String { "api/items/\(id)/cover" }
15 | public var accept: String { "image/*" }
16 |
17 | public var queryItems: [URLQueryItem]? {
18 | var items = [URLQueryItem]()
19 | if let width {
20 | items.append(.init(name: "width", value: String(width)))
21 | }
22 | if let height {
23 | items.append(.init(name: "height", value: String(height)))
24 | }
25 | if let format {
26 | items.append(.init(name: "format", value: format.rawValue))
27 | }
28 | if raw {
29 | items.append(.init(name: "raw", value: "1"))
30 | }
31 | return items
32 | }
33 |
34 | private let id: String
35 | private let width: Int?
36 | private let height: Int?
37 | private let format: Format?
38 | private let raw: Bool
39 |
40 | /// Create a new request to retrieve a library item's cover.
41 | /// - Parameters:
42 | /// - id: The ID of the library item to retrieve the cover for
43 | /// - width: The requested width of the cover image
44 | /// - height: The requested height of the cover image
45 | /// - format: The requested format of the cover image
46 | /// - raw: Whether to get the raw cover image file instead of a scaled version
47 | public init(
48 | id: String,
49 | width: Int? = nil,
50 | height: Int? = nil,
51 | format: Format? = nil,
52 | raw: Bool = false
53 | ) {
54 | self.id = id
55 | self.width = width
56 | self.height = height
57 | self.format = format
58 | self.raw = raw
59 | }
60 |
61 | public enum Format: String, Sendable {
62 | case webp, jpeg
63 | }
64 | }
65 | }
66 |
67 | public extension Audiobookshelf.Request.GetLibraryItemCover {
68 | typealias Response = Data
69 |
70 | static func response(from data: Data) throws -> Data {
71 | data
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibrarySeries.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibrarySeries.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 1/3/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint returns a library's series.
13 | struct GetLibrarySeries: ResourceRequest {
14 | public var path: String { "api/libraries/\(libraryID)/series" }
15 |
16 | public var queryItems: [URLQueryItem]? {
17 | [
18 | URLQueryItem(name: "page", value: page),
19 | URLQueryItem(name: "limit", value: limit),
20 | URLQueryItem(name: "sortBy", value: sortBy),
21 | URLQueryItem(name: "desc", value: desc),
22 | URLQueryItem(name: "minified", value: minified),
23 | ]
24 | }
25 |
26 | private let libraryID: String
27 | private let page: Int
28 | private let limit: Int
29 | private let sortBy: String
30 | private let desc: Bool
31 | private let minified: Bool
32 |
33 | /// - Parameters:
34 | /// - libraryID: The ID of the library.
35 | /// - page: The page number (0 indexed) to request.
36 | /// - limit: Limit the number of returned results per page. Must be greater than 0.
37 | /// - sortBy: What to sort the results by. By default, the results will be sorted by series name. Other sort options are: numBooks, totalDuration, and addedAt.
38 | /// - desc: Whether to reverse the sort order.
39 | /// - minified:
40 | public init(
41 | libraryID: String,
42 | page: Int,
43 | limit: Int,
44 | sortBy: String = "name",
45 | desc: Bool = false,
46 | minified: Bool = false
47 | ) {
48 | self.libraryID = libraryID
49 | self.page = page
50 | self.limit = limit
51 | self.sortBy = sortBy
52 | self.desc = desc
53 | self.minified = minified
54 | }
55 | }
56 | }
57 |
58 | public extension Audiobookshelf.Request.GetLibrarySeries {
59 | struct Response: Codable, Sendable {
60 | public let results: [Series]
61 | public let total: Int
62 | public let limit: Int
63 | public let page: Int
64 | public let sortBy: String?
65 | public let desc: Bool?
66 | public let minified: Bool?
67 | public let include: String?
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/PlaybackSession.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PlaybackSession.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct PlaybackSession: Codable, Hashable, Identifiable, Sendable {
12 | /// The ID of the playback session.
13 | public let id: String
14 | /// The ID of the user the playback session is for.
15 | public let userId: String?
16 | /// The ID of the library that contains the library item.
17 | public let libraryId: String?
18 | /// The ID of the library item.
19 | public let libraryItemId: String
20 | /// The ID of the podcast episode. Will be null if this playback session was started without an episode ID.
21 | public let episodeId: String?
22 | /// The media type of the library item. Will be book or podcast.
23 | public let mediaType: String
24 | /// The title of the playing item to show to the user.
25 | public let displayTitle: String?
26 | /// The author of the playing item to show to the user.
27 | public let displayAuthor: String?
28 | /// The cover path of the library item's media.
29 | public let coverPath: String?
30 | /// The total duration (in seconds) of the playing item.
31 | public let duration: TimeInterval?
32 | /// What play method the playback session is using. See below for values.
33 | public let playMethod: PlayMethod?
34 | /// The given media player when the playback session was requested.
35 | public let mediaPlayer: String?
36 | /// The given device info when the playback session was requested.
37 | public let deviceInfo: DeviceInfo
38 | /// The server version the playback session was started with.
39 | public let serverVersion: String?
40 | /// The day (in the format YYYY-MM-DD) the playback session was started.
41 | public let date: String?
42 | /// The day of the week the playback session was started.
43 | public let dayOfWeek: String?
44 | /// The amount of time (in seconds) the user has spent listening using this playback session.
45 | public let timeListening: TimeInterval?
46 | /// The time (in seconds) where the playback session started.
47 | public let startTime: TimeInterval?
48 | /// The current time (in seconds) of the playback position.
49 | public let currentTime: TimeInterval?
50 | /// The time (in ms since POSIX epoch) when the playback session was started.
51 | public let startedAt: Date?
52 | /// The time (in ms since POSIX epoch) when the playback session was last updated.
53 | public let updatedAt: Date?
54 | }
55 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/OAuth2AuthorizationRequestTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OAuth2AuthorizationRequestTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct OAuth2AuthorizationRequestTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.OAuth2AuthorizationRequest(
16 | codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
17 | redirectURI: URL(string: "app://oauth-callback")!,
18 | clientID: "my-client-id",
19 | state: "random-state-string"
20 | )
21 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
22 |
23 | let data = RequestData(request: request)
24 |
25 | #expect(data.baseURL == testURL.appendingPathComponent("/auth/openid"))
26 | #expect(data.headers == [
27 | "Accept": "*/*",
28 | "Authorization": "Bearer my-token",
29 | ])
30 |
31 | #expect(data.queryItems == [
32 | "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
33 | "code_challenge_method": "S256",
34 | "response_type": "code",
35 | "redirect_uri": "app://oauth-callback",
36 | "client_id": "my-client-id",
37 | "state": "random-state-string",
38 | ])
39 | }
40 |
41 | @Test func request_customParameters() throws {
42 | let request = try Audiobookshelf.Request.OAuth2AuthorizationRequest(
43 | codeChallenge: "custom-challenge",
44 | codeChallengeMethod: "plain",
45 | responseType: "token",
46 | redirectURI: URL(string: "https://example.com/callback")!,
47 | clientID: "another-client",
48 | state: "custom-state"
49 | )
50 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
51 |
52 | let data = RequestData(request: request)
53 |
54 | #expect(data.baseURL == testURL.appendingPathComponent("/auth/openid"))
55 | #expect(data.headers == [
56 | "Accept": "*/*",
57 | "Authorization": "Bearer my-token",
58 | ])
59 |
60 | #expect(data.queryItems == [
61 | "code_challenge": "custom-challenge",
62 | "code_challenge_method": "plain",
63 | "response_type": "token",
64 | "redirect_uri": "https://example.com/callback",
65 | "client_id": "another-client",
66 | "state": "custom-state",
67 | ])
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/DeviceInfo.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeviceInfo.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | public struct DeviceInfo: Codable, Hashable, Identifiable, Sendable {
10 | /// Unique identifier.
11 | public let id: String?
12 | /// User identifier.
13 | public let userId: String?
14 | /// Device identifier, as provided in the request.
15 | public let deviceId: String?
16 | /// The IP address that the request came from.
17 | public let ipAddress: String?
18 | /// The browser name, taken from the user agent.
19 | public let browserName: String?
20 | /// The browser version, taken from the user agent.
21 | public let browserVersion: String?
22 | /// The name of OS, taken from the user agent.
23 | public let osName: String?
24 | /// The version of the OS, taken from the user agent.
25 | public let osVersion: String?
26 | /// The device name, constructed automatically from other attributes.
27 | public let deviceName: String?
28 | /// The device type, taken from the user agent.
29 | public let deviceType: String?
30 | /// The client device's manufacturer, as provided in the request.
31 | public let manufacturer: String?
32 | /// The client device's model, as provided in the request.
33 | public let model: String?
34 | /// Name of the client, as provided in the request.
35 | public let clientName: String?
36 | /// Version of the client, as provided in the request.
37 | public let clientVersion: String?
38 |
39 | public init(
40 | id: String? = nil,
41 | userId: String? = nil,
42 | deviceId: String,
43 | ipAddress: String? = nil,
44 | browserName: String? = nil,
45 | browserVersion: String? = nil,
46 | osName: String? = nil,
47 | osVersion: String? = nil,
48 | deviceName: String? = nil,
49 | deviceType: String? = nil,
50 | manufacturer: String? = nil,
51 | model: String? = nil,
52 | clientName: String,
53 | clientVersion: String
54 | ) {
55 | self.id = id
56 | self.userId = userId
57 | self.deviceId = deviceId
58 | self.ipAddress = ipAddress
59 | self.browserName = browserName
60 | self.browserVersion = browserVersion
61 | self.osName = osName
62 | self.osVersion = osVersion
63 | self.deviceName = deviceName
64 | self.deviceType = deviceType
65 | self.manufacturer = manufacturer
66 | self.model = model
67 | self.clientName = clientName
68 | self.clientVersion = clientVersion
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetAuthorTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetAuthorTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetAuthorTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetAuthor(
16 | id: "aut_z3leimgybl7uf3y4ab"
17 | )
18 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
19 |
20 | let data = RequestData(request: request)
21 |
22 | #expect(data.baseURL == testURL.appendingPathComponent("/api/authors/aut_z3leimgybl7uf3y4ab"))
23 | #expect(data.headers == [
24 | "Accept": "application/json",
25 | "Authorization": "Bearer my-token",
26 | ])
27 |
28 | #expect(data.queryItems == [
29 | "include": "items",
30 | ])
31 | }
32 |
33 | @Test func response() throws {
34 | let response = try loadResponse(
35 | "author",
36 | for: Audiobookshelf.Request.GetAuthor.self
37 | )
38 |
39 | #expect(response.id == "aut_z3leimgybl7uf3y4ab")
40 | #expect(response.asin == "B000APZOQA")
41 | #expect(response.name == "Terry Goodkind")
42 | #expect(response.description == "Terry Goodkind is a #1 New York Times Bestselling Author and creator of the critically acclaimed masterwork, 'The Sword of Truth'. He has written 30+ major, bestselling novels, has been published in more than 20 languages world-wide, and has sold more than 26 Million books. 'The Sword of Truth' is a revered literary tour de force, comprised of 17 volumes, borne from over 25 years of dedicated writing.")
43 | #expect(response.imagePath == "/metadata/authors/aut_z3leimgybl7uf3y4ab/image.jpg")
44 | #expect(response.addedAt == Date(timeIntervalSince1970: 1_650_621_073_750 / 1000))
45 | #expect(response.updatedAt == Date(timeIntervalSince1970: 1_650_621_073_750 / 1000))
46 | #expect(response.libraryId == "lib_c1u6t4p45c35rf0nzd")
47 |
48 | #expect(response.libraryItems.count == 1)
49 | let libraryItem = response.libraryItems[0]
50 | #expect(libraryItem.id == "li_8gch9ve09orgn4fdz8")
51 | #expect(libraryItem.ino == "649641337522215266")
52 | #expect(libraryItem.libraryId == "lib_c1u6t4p45c35rf0nzd")
53 | #expect(libraryItem.path == "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule")
54 | #expect(libraryItem.mediaType == .book)
55 | #expect(libraryItem.media.metadata.title == "Wizards First Rule")
56 | #expect(libraryItem.media.metadata.authorName == "Terry Goodkind")
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/BatchUpdateMediaProgress.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BatchUpdateMediaProgress.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 3/8/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint batch creates/updates your media progress.
13 | struct BatchUpdateMediaProgress: ResourceRequest {
14 | public let path = "api/me/progress/batch/update"
15 | public let httpMethod = "PATCH"
16 | public var httpBody: Codable? {
17 | parameters
18 | }
19 |
20 | private let parameters: [Parameters]
21 |
22 | public init(_ parameters: [Parameters]) {
23 | self.parameters = parameters
24 | }
25 | }
26 | }
27 |
28 | public extension Audiobookshelf.Request.BatchUpdateMediaProgress {
29 | typealias Response = Data
30 |
31 | static func response(from data: Data) throws -> Data {
32 | data
33 | }
34 |
35 | struct Parameters: Codable, Hashable, Sendable {
36 | /// The ID of the library item the media progress is for.
37 | public let libraryItemId: String
38 | /// The ID of the podcast episode the media progress is for.
39 | public let episodeId: String?
40 | /// The total duration (in seconds) of the media.
41 | public let duration: TimeInterval
42 | /// The percentage completion progress of the media. Will automatically be set to 1 if the media is finished.
43 | public let progress: Double
44 | /// The current time (in seconds) of your progress.
45 | public let currentTime: TimeInterval
46 | /// Whether the media is finished.
47 | public let isFinished: Bool
48 | /// Whether the media will be hidden from the "Continue Listening" shelf.
49 | public let hideFromContinueListening: Bool?
50 | /// The time when the user finished the media. The default will be Date.now() if isFinished is true.
51 | public let finishedAt: Date?
52 | /// The time when the user started consuming the media. The default will be the value of finishedAt if isFinished is true.
53 | public let startedAt: Date?
54 |
55 | public init(
56 | libraryItemId: String,
57 | episodeId: String? = nil,
58 | duration: TimeInterval,
59 | progress: Double,
60 | currentTime: TimeInterval,
61 | isFinished: Bool,
62 | hideFromContinueListening: Bool? = nil,
63 | finishedAt: Date? = nil,
64 | startedAt: Date? = nil
65 | ) {
66 | self.libraryItemId = libraryItemId
67 | self.episodeId = episodeId
68 | self.duration = duration
69 | self.progress = progress
70 | self.currentTime = currentTime
71 | self.isFinished = isFinished
72 | self.hideFromContinueListening = hideFromContinueListening
73 | self.finishedAt = finishedAt
74 | self.startedAt = startedAt
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetUserTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetUserTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetUserTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetUser()
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("/api/me"))
21 | #expect(data.headers == [
22 | "Accept": "application/json",
23 | "Authorization": "Bearer my-token",
24 | ])
25 |
26 | #expect(data.queryItems.isEmpty)
27 | }
28 |
29 | @Test func response() throws {
30 | let response = try loadResponse(
31 | "user",
32 | for: Audiobookshelf.Request.GetUser.self
33 | )
34 |
35 | #expect(response.id == "a7ea108d-ed8c-4179-bedd-07cf4db866aa")
36 | #expect(response.oldUserId == "root")
37 | #expect(response.username == "root")
38 | #expect(response.email == nil)
39 | #expect(response.type == .root)
40 | #expect(response.token == "my-token")
41 | #expect(response.isActive == true)
42 | #expect(response.isLocked == false)
43 | #expect(response.lastSeen == Date(timeIntervalSince1970: 1_712_674_531_253 / 1000))
44 | #expect(response.createdAt == Date(timeIntervalSince1970: 1_650_709_131_028 / 1000))
45 | // hasOpenIDLink field not present in User model
46 |
47 | // Check permissions
48 | #expect(response.permissions.download == true)
49 | #expect(response.permissions.update == true)
50 | #expect(response.permissions.delete == true)
51 | #expect(response.permissions.upload == true)
52 | #expect(response.permissions.accessAllLibraries == true)
53 | #expect(response.permissions.accessAllTags == true)
54 | #expect(response.permissions.accessExplicitContent == true)
55 |
56 | // Check media progress
57 | #expect(response.mediaProgress.count == 1)
58 | let mediaProgress = response.mediaProgress[0]
59 | #expect(mediaProgress.id == "8e7ed07e-19b4-4ccf-9668-03a7ed20f1dd")
60 | #expect(mediaProgress.libraryItemId == "27bec22a-0902-4226-a6ba-677a95fd993d")
61 | #expect(mediaProgress.isFinished == false)
62 |
63 | // Check bookmarks
64 | #expect(response.bookmarks.count == 1)
65 | let bookmark = response.bookmarks[0]
66 | #expect(bookmark.libraryItemId == "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458")
67 | #expect(bookmark.title == "1234")
68 | #expect(bookmark.time == 6446)
69 | #expect(bookmark.createdAt == Date(timeIntervalSince1970: 1_723_386_963_225 / 1000))
70 |
71 | // Check arrays
72 | #expect(response.seriesHideFromContinueListening.count == 0)
73 | #expect(response.librariesAccessible.count == 0)
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/library_series.json:
--------------------------------------------------------------------------------
1 | {
2 | "results": [
3 | {
4 | "id": "ser_cabkj4jeu8be3rap4g",
5 | "name": "Sword of Truth",
6 | "nameIgnorePrefix": "Sword of Truth",
7 | "description": "The Sword of Truth is a series of epic fantasy novels by Terry Goodkind.",
8 | "addedAt": 1650621073750,
9 | "updatedAt": 1650621110769,
10 | "libraryId": "lib_c1u6t4p45c35rf0nzd",
11 | "books": [
12 | {
13 | "id": "li_8gch9ve09orgn4fdz8",
14 | "ino": "649641337522215266",
15 | "oldLibraryItemId": null,
16 | "libraryId": "lib_c1u6t4p45c35rf0nzd",
17 | "folderId": "fol_bev1zuxhb0j0s1wehr",
18 | "path": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule",
19 | "relPath": "Terry Goodkind/Sword of Truth/Wizards First Rule",
20 | "isFile": false,
21 | "mtimeMs": 1650621074299,
22 | "ctimeMs": 1650621074299,
23 | "birthtimeMs": 0,
24 | "addedAt": 1650621073750,
25 | "updatedAt": 1650621110769,
26 | "lastScan": 1651830827825,
27 | "scanVersion": "2.0.21",
28 | "isMissing": false,
29 | "isInvalid": false,
30 | "mediaType": "book",
31 | "media": {
32 | "id": "book_1",
33 | "libraryItemId": "li_8gch9ve09orgn4fdz8",
34 | "metadata": {
35 | "title": "Wizards First Rule",
36 | "titleIgnorePrefix": "Wizards First Rule",
37 | "subtitle": null,
38 | "authors": [
39 | {
40 | "id": "aut_z3leimgybl7uf3y4ab",
41 | "name": "Terry Goodkind"
42 | }
43 | ],
44 | "narrators": ["Sam Tsoutsouvas"],
45 | "genres": ["Fantasy"],
46 | "publishedYear": "2008",
47 | "publishedDate": null,
48 | "publisher": "Brilliance Audio",
49 | "description": "The masterpiece that started Terry Goodkind's New York Times bestselling epic Sword of Truth",
50 | "isbn": null,
51 | "asin": "B002V0QK4C",
52 | "language": null,
53 | "explicit": false,
54 | "authorName": "Terry Goodkind",
55 | "authorNameLF": "Goodkind, Terry",
56 | "narratorName": "Sam Tsoutsouvas",
57 | "seriesName": "Sword of Truth",
58 | "abridged": false
59 | },
60 | "coverPath": "/metadata/items/li_8gch9ve09orgn4fdz8/cover.jpg",
61 | "tags": [],
62 | "audioFiles": [],
63 | "chapters": [],
64 | "duration": 33854.905,
65 | "size": 268824228,
66 | "tracks": [],
67 | "ebookFile": null,
68 | "numTracks": 20,
69 | "numAudioFiles": 20,
70 | "numChapters": 0
71 | },
72 | "libraryFiles": [],
73 | "size": 268824228,
74 | "numFiles": 20
75 | }
76 | ]
77 | }
78 | ],
79 | "total": 1,
80 | "limit": 10,
81 | "page": 0,
82 | "sortBy": "name",
83 | "desc": false,
84 | "minified": false,
85 | "include": null
86 | }
--------------------------------------------------------------------------------
/Example/Example/Views/BookDetailView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BookDetailView.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 23/4/2024.
6 | //
7 |
8 | import AudiobookshelfKit
9 | import SwiftUI
10 |
11 | struct BookDetailView: View {
12 | let id: String
13 | let title: String
14 |
15 | @Environment(\.client) private var client
16 | @Environment(ServerInfo.self) private var serverInfo
17 | @Environment(\.isPreview) private var isPreview
18 |
19 | @State private var item: LibraryItemExpanded?
20 | @State private var errorMessage: String?
21 |
22 | init(id: String, title: String) {
23 | self.id = id
24 | self.title = title
25 | }
26 |
27 | #if DEBUG
28 | init(item: LibraryItemExpanded) {
29 | id = item.id
30 | title = item.media.metadata.title
31 | _item = State(initialValue: item)
32 | }
33 | #endif
34 |
35 | private func getLibraryItem() async {
36 | let request = Audiobookshelf.Request.GetLibraryItem(id: id)
37 | let result = await client.request(request, from: serverInfo.url, token: serverInfo.token)
38 | switch result {
39 | case let .success(response):
40 | item = response
41 | case let .failure(error):
42 | errorMessage = error.description
43 | }
44 | }
45 |
46 | var body: some View {
47 | Group {
48 | if let item {
49 | BookView(item: item)
50 | } else if let errorMessage {
51 | Text(errorMessage)
52 | .foregroundStyle(.red)
53 | } else {
54 | ProgressView()
55 | }
56 | }
57 | .task {
58 | if !isPreview {
59 | await getLibraryItem()
60 | }
61 | }
62 | .navigationBarTitleDisplayMode(.inline)
63 | .navigationTitle(title)
64 | }
65 | }
66 |
67 | private struct BookView: View {
68 | let item: LibraryItemExpanded
69 |
70 | @Environment(Player.self) private var player
71 | @Environment(ServerInfo.self) private var serverInfo
72 |
73 | var body: some View {
74 | List {
75 | VStack {
76 | Cover(itemID: item.id, size: 150)
77 | Text(item.media.metadata.title)
78 | .font(.title)
79 | .multilineTextAlignment(.center)
80 | Text("By \(item.media.metadata.authorName)")
81 | Button(action: {
82 | player.play(item: item, serverInfo: serverInfo)
83 | }, label: {
84 | Text("Play")
85 | })
86 | .buttonStyle(.borderedProminent)
87 | }
88 | .frame(maxWidth: .infinity)
89 | .listRowBackground(EmptyView())
90 | Section("Chapters") {
91 | ForEach(item.media.chapters) { chapter in
92 | Text(chapter.title)
93 | }
94 | }
95 | }
96 | }
97 | }
98 |
99 | #Preview {
100 | let data = try! Data(contentsOf: Bundle.main.url(forResource: "library_item", withExtension: "json")!)
101 | let item = try! JSONDecoder().decode(LibraryItemExpanded.self, from: data)
102 |
103 | return NavigationStack {
104 | BookDetailView(item: item)
105 | .environment(\.isPreview, true)
106 | .environment(ServerInfo.mock)
107 | .environment(Player())
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/_Request.swift:
--------------------------------------------------------------------------------
1 | //
2 | // _Request.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Describes a request to an Audiobookshelf resource (e.g. server).
12 | public protocol ResourceRequest: Sendable {
13 | associatedtype Response: Sendable
14 |
15 | var path: String { get }
16 | var httpMethod: String { get }
17 | var accept: String { get }
18 |
19 | var headers: [String: String]? { get }
20 | var queryItems: [URLQueryItem]? { get }
21 | var httpBody: Codable? { get }
22 |
23 | static func response(from data: Data) throws -> Response
24 | }
25 |
26 | public extension ResourceRequest {
27 | var httpMethod: String { "GET" }
28 | var headers: [String: String]? { nil }
29 | var accept: String { "application/json" }
30 | var queryItems: [URLQueryItem]? { nil }
31 | var httpBody: Codable? { nil }
32 | }
33 |
34 | public extension ResourceRequest where Response: Codable {
35 | static func _response(from data: Data) throws -> Response {
36 | let decoder = JSONDecoder()
37 | decoder.dateDecodingStrategy = .millisecondsSince1970
38 | return try decoder.decode(Response.self, from: data)
39 | }
40 |
41 | static func response(from data: Data) throws -> Response {
42 | try _response(from: data)
43 | }
44 | }
45 |
46 | public extension ResourceRequest {
47 | func asURLRequest(
48 | from url: URL,
49 | using token: String?,
50 | tokenStrategy: TokenStrategy = .header,
51 | customHeaders: [String: String]
52 | ) throws -> URLRequest {
53 | try _asURLRequest(from: url, using: token, tokenStrategy: tokenStrategy, customHeaders: customHeaders)
54 | }
55 |
56 | internal func _asURLRequest(
57 | from url: URL,
58 | using token: String?,
59 | tokenStrategy: TokenStrategy,
60 | customHeaders: [String: String]
61 | ) throws -> URLRequest {
62 | var queryItems = queryItems ?? []
63 | if tokenStrategy == .queryItem, let token {
64 | queryItems.append(URLQueryItem(name: "token", value: token))
65 | }
66 |
67 | guard let url = url.appendingPathComponent(path).appendingQueryItems(queryItems) else {
68 | throw AudiobookshelfError.invalidRequest(.invalidQueryItems(queryItems))
69 | }
70 |
71 | var request = URLRequest(url: url)
72 | request.httpMethod = httpMethod
73 | request.addValue(accept, forHTTPHeaderField: "accept")
74 |
75 | if tokenStrategy == .header, let token {
76 | request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
77 | }
78 |
79 | for (key, value) in headers ?? [:] {
80 | request.addValue(value, forHTTPHeaderField: key)
81 | }
82 |
83 | for (key, value) in customHeaders {
84 | request.addValue(value, forHTTPHeaderField: key)
85 | }
86 |
87 | if let httpBody {
88 | request.addValue("application/json", forHTTPHeaderField: "Content-Type")
89 | let encoder = JSONEncoder()
90 | encoder.dateEncodingStrategy = .millisecondsSince1970
91 | request.httpBody = try encoder.encode(httpBody)
92 | }
93 |
94 | return request
95 | }
96 | }
97 |
98 | public enum TokenStrategy {
99 | case header
100 | case queryItem
101 | }
102 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetLibrarySeriesTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibrarySeriesTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetLibrarySeriesTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetLibrarySeries(
16 | libraryID: "my-library",
17 | page: 0,
18 | limit: 10
19 | )
20 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
21 |
22 | let data = RequestData(request: request)
23 |
24 | #expect(data.baseURL == testURL.appendingPathComponent("/api/libraries/my-library/series"))
25 | #expect(data.headers == [
26 | "Accept": "application/json",
27 | "Authorization": "Bearer my-token",
28 | ])
29 |
30 | #expect(data.queryItems == [
31 | "page": "0",
32 | "limit": "10",
33 | "sortBy": "name",
34 | "desc": "0",
35 | "minified": "0",
36 | ])
37 | }
38 |
39 | @Test func request_withOptionalParameters() throws {
40 | let request = try Audiobookshelf.Request.GetLibrarySeries(
41 | libraryID: "my-library",
42 | page: 2,
43 | limit: 25,
44 | sortBy: "numBooks",
45 | desc: true,
46 | minified: true
47 | )
48 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
49 |
50 | let data = RequestData(request: request)
51 |
52 | #expect(data.baseURL == testURL.appendingPathComponent("/api/libraries/my-library/series"))
53 | #expect(data.headers == [
54 | "Accept": "application/json",
55 | "Authorization": "Bearer my-token",
56 | ])
57 |
58 | #expect(data.queryItems == [
59 | "page": "2",
60 | "limit": "25",
61 | "sortBy": "numBooks",
62 | "desc": "1",
63 | "minified": "1",
64 | ])
65 | }
66 |
67 | @Test func response() throws {
68 | let response = try loadResponse(
69 | "library_series",
70 | for: Audiobookshelf.Request.GetLibrarySeries.self
71 | )
72 |
73 | #expect(response.total == 1)
74 | #expect(response.limit == 10)
75 | #expect(response.page == 0)
76 | #expect(response.sortBy == "name")
77 | #expect(response.desc == false)
78 | #expect(response.minified == false)
79 | #expect(response.include == nil)
80 | #expect(response.results.count == 1)
81 |
82 | let series = response.results[0]
83 | #expect(series.id == "ser_cabkj4jeu8be3rap4g")
84 | #expect(series.name == "Sword of Truth")
85 | #expect(series.nameIgnorePrefix == "Sword of Truth")
86 | #expect(series.description == "The Sword of Truth is a series of epic fantasy novels by Terry Goodkind.")
87 | #expect(series.addedAt == Date(timeIntervalSince1970: 1_650_621_073_750 / 1000))
88 | #expect(series.updatedAt == Date(timeIntervalSince1970: 1_650_621_110_769 / 1000))
89 | #expect(series.libraryId == "lib_c1u6t4p45c35rf0nzd")
90 |
91 | #expect(series.books.count == 1)
92 | let book = series.books[0]
93 | #expect(book.id == "li_8gch9ve09orgn4fdz8")
94 | #expect(book.mediaType == .book)
95 | #expect(book.media.metadata.title == "Wizards First Rule")
96 | #expect(book.media.metadata.seriesName == "Sword of Truth")
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/AudiobookshelfKit.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AudiobookshelfKit.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | // MARK: - Client.
12 |
13 | public struct Audiobookshelf: Sendable {
14 | private let session: URLSession
15 |
16 | enum Constants {
17 | static let acceptableStatusCodes = 200 ..< 300
18 | }
19 |
20 | public init(sessionConfiguration: URLSessionConfiguration) {
21 | self.session = URLSession(configuration: sessionConfiguration)
22 | }
23 |
24 | private func request(
25 | _ request: URLRequest,
26 | transformer: @escaping (Data) throws -> Response
27 | ) async -> Result {
28 | let data: Data
29 | let response: URLResponse
30 |
31 | do {
32 | (data, response) = try await session.data(for: request)
33 | } catch let error as URLError {
34 | return .failure(.networkError(request.url!, .transportError(error)))
35 | } catch {
36 | return .failure(.networkError(request.url!, .unknown(error)))
37 | }
38 |
39 | if let response = response as? HTTPURLResponse {
40 | guard Constants.acceptableStatusCodes.contains(response.statusCode) else {
41 | return .failure(.networkError(request.url!, .unacceptableStatusCode(response.statusCode)))
42 | }
43 | }
44 |
45 | do {
46 | return try .success(transformer(data))
47 | } catch {
48 | return .failure(.decodingFailed(request.url!, error))
49 | }
50 | }
51 |
52 | @discardableResult public func request(
53 | _ request: Request,
54 | from url: URL,
55 | token: String? = nil,
56 | customHeaders: [String: String] = [:]
57 | ) async -> Result {
58 | let urlRequest: URLRequest
59 |
60 | do {
61 | urlRequest = try request.asURLRequest(
62 | from: url,
63 | using: token,
64 | customHeaders: customHeaders
65 | )
66 | } catch let error as AudiobookshelfError {
67 | return .failure(error)
68 | } catch {
69 | return .failure(.invalidRequest(.unknown(error)))
70 | }
71 |
72 | return await self.request(
73 | urlRequest,
74 | transformer: Request.response(from:)
75 | )
76 | }
77 | }
78 |
79 | // MARK: - Namespaces.
80 |
81 | public extension Audiobookshelf {
82 | enum Request {}
83 | }
84 |
85 | // MARK: - Errors.
86 |
87 | public enum AudiobookshelfError: Error, Sendable {
88 | /// An error occurred while constructing the request.
89 | case invalidRequest(RequestFailureReason)
90 | /// An networking error occurred.
91 | case networkError(URL, NetworkFailureReason)
92 | /// An error occurred while decoding the response.
93 | case decodingFailed(URL, Error)
94 |
95 | /// A token was not supplied for a request that required one.
96 | case notAuthenticated
97 |
98 | public enum RequestFailureReason: Sendable {
99 | case invalidURL(String)
100 | case invalidQueryItems([URLQueryItem])
101 | case unknown(Error)
102 | }
103 |
104 | public enum NetworkFailureReason: Sendable {
105 | case unacceptableStatusCode(Int)
106 | case transportError(URLError)
107 | case unknown(Error)
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibraryCollections.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryCollections.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 25/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves a library's collections.
13 | struct GetLibraryCollections: ResourceRequest {
14 | public var path: String { "api/libraries/\(libraryID)/collections" }
15 |
16 | public var queryItems: [URLQueryItem]? {
17 | var items = [
18 | URLQueryItem(name: "limit", value: String(limit)),
19 | URLQueryItem(name: "page", value: String(page)),
20 | ]
21 | if let sort {
22 | items.append(URLQueryItem(name: "sort", value: sort))
23 | }
24 | if let desc {
25 | items.append(URLQueryItem(name: "desc", value: desc))
26 | }
27 | if let minified {
28 | items.append(URLQueryItem(name: "minified", value: minified))
29 | }
30 | if let include {
31 | items.append(URLQueryItem(name: "include", value: include))
32 | }
33 | return items
34 | }
35 |
36 | private let libraryID: String
37 | private let page: Int
38 | private let limit: Int
39 | private let sort: String?
40 | private let desc: Bool?
41 | private let minified: Bool?
42 | private let include: String?
43 |
44 | /// - Parameters:
45 | /// - libraryID: The ID of the library.
46 | /// - page: The page number (0 indexed) to request. If there is no limit applied, then page will have no effect and all results will be returned.
47 | /// - limit: Limit the number of returned results per page. If 0, no limit will be applied.
48 | /// - sort: What to sort the results by.
49 | /// - desc: Whether to reverse the sort order.
50 | /// - minified: Whether to request minified objects.
51 | /// - include: A comma separated list of what to include with the library items. The only current option is rssfeed.
52 | public init(
53 | libraryID: String,
54 | page: Int,
55 | limit: Int,
56 | sort: String? = nil,
57 | desc: Bool? = nil,
58 | minified: Bool? = nil,
59 | include: String? = nil
60 | ) {
61 | self.libraryID = libraryID
62 | self.limit = limit
63 | self.page = page
64 | self.sort = sort
65 | self.desc = desc
66 | self.minified = minified
67 | self.include = include
68 | }
69 | }
70 | }
71 |
72 | public extension Audiobookshelf.Request.GetLibraryCollections {
73 | struct Response: Codable, Sendable {
74 | /// The requested collections.
75 | public let results: [Collection]
76 | /// The total number of results.
77 | public let total: Int
78 | /// The limit set in the request.
79 | public let limit: Int
80 | /// The page set in request.
81 | public let page: Int
82 | /// The sort set in the request. Will not exist if no sort was set.
83 | public let sortBy: String?
84 | /// Whether to reverse the sort order.
85 | public let desc: Bool?
86 | /// The filter set in the request, URL decoded. Will not exist if no filter was set.
87 | public let filterBy: String?
88 | /// Whether minified was set in the request.
89 | public let minified: Bool?
90 | /// The requested include.
91 | public let include: String?
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetLibrariesTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibrariesTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetLibrariesTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetLibraries()
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("api/libraries"))
21 | #expect(data.headers == [
22 | "Accept": "application/json",
23 | "Authorization": "Bearer my-token",
24 | ])
25 | }
26 |
27 | @Test func response() throws {
28 | let response = try loadResponse(
29 | "libraries",
30 | for: Audiobookshelf.Request.GetLibraries.self
31 | )
32 |
33 | let libraries = response.libraries
34 | #expect(libraries.count == 2)
35 | let mainLibrary = libraries[0]
36 | #expect(mainLibrary.id == "lib_5yvub9dqvctlcrza6h")
37 | #expect(mainLibrary.name == "Main")
38 | #expect(mainLibrary.folders.count == 1)
39 | let audiobooksFolder = mainLibrary.folders[0]
40 | #expect(audiobooksFolder.id == "audiobooks")
41 | #expect(audiobooksFolder.fullPath == "/audiobooks")
42 | #expect(audiobooksFolder.libraryId == "main")
43 | #expect(audiobooksFolder.addedAt == Date(timeIntervalSince1970: 1_633_522_963_509 / 1000))
44 | #expect(mainLibrary.displayOrder == 1)
45 | #expect(mainLibrary.icon == "audiobookshelf")
46 | #expect(mainLibrary.mediaType == .book)
47 | #expect(mainLibrary.provider == "audible")
48 | #expect(mainLibrary.settings.coverAspectRatio == 1)
49 | #expect(mainLibrary.settings.disableWatcher == false)
50 | #expect(mainLibrary.settings.skipMatchingMediaWithAsin == false)
51 | #expect(mainLibrary.settings.skipMatchingMediaWithIsbn == false)
52 | #expect(mainLibrary.settings.autoScanCronExpression == nil)
53 | #expect(mainLibrary.createdAt == Date(timeIntervalSince1970: 1_633_522_963_509 / 1000))
54 | #expect(mainLibrary.lastUpdate == Date(timeIntervalSince1970: 1_646_520_916_818 / 1000))
55 |
56 | let podcastsLibrary = libraries[1]
57 | #expect(podcastsLibrary.id == "lib_c1u6t4p45c35rf0nzd")
58 | #expect(podcastsLibrary.name == "Podcasts")
59 | #expect(podcastsLibrary.folders.count == 1)
60 | let podcastsFolder = podcastsLibrary.folders[0]
61 | #expect(podcastsFolder.id == "fol_bev1zuxhb0j0s1wehr")
62 | #expect(podcastsFolder.fullPath == "/podcasts")
63 | #expect(podcastsFolder.libraryId == "lib_c1u6t4p45c35rf0nzd")
64 | #expect(podcastsFolder.addedAt == Date(timeIntervalSince1970: 1_650_462_940_610 / 1000))
65 | #expect(podcastsLibrary.displayOrder == 4)
66 | #expect(podcastsLibrary.icon == "database")
67 | #expect(podcastsLibrary.mediaType == .podcast)
68 | #expect(podcastsLibrary.provider == "itunes")
69 | #expect(podcastsLibrary.settings.coverAspectRatio == 1)
70 | #expect(podcastsLibrary.settings.disableWatcher == false)
71 | #expect(podcastsLibrary.settings.skipMatchingMediaWithAsin == false)
72 | #expect(podcastsLibrary.settings.skipMatchingMediaWithIsbn == false)
73 | #expect(podcastsLibrary.settings.autoScanCronExpression == nil)
74 | #expect(podcastsLibrary.createdAt == Date(timeIntervalSince1970: 1_650_462_940_610 / 1000))
75 | #expect(podcastsLibrary.lastUpdate == Date(timeIntervalSince1970: 1_650_462_940_610 / 1000))
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Example/Example/Views/SignInView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SignInView.swift
3 | // Example
4 | //
5 | // Created by Lachlan Charlick on 9/4/2024.
6 | //
7 |
8 | import AudiobookshelfKit
9 | import SwiftUI
10 |
11 | struct SignInView: View {
12 | @Environment(\.client) private var client
13 | @Environment(Router.self) private var router
14 | @Environment(ServerInfo.self) private var serverInfo
15 |
16 | @State private var isLoading = false
17 | @State private var successMessage: String?
18 | @State private var errorMessage: String?
19 | @State private var server = ProcessInfo.processInfo.environment["AUDIOBOOKSHELF_SERVER"] ?? ""
20 | @State private var username = ProcessInfo.processInfo.environment["AUDIOBOOKSHELF_USERNAME"] ?? ""
21 | @State private var password = ProcessInfo.processInfo.environment["AUDIOBOOKSHELF_PASSWORD"] ?? ""
22 |
23 | private func signIn() async {
24 | errorMessage = nil
25 | successMessage = nil
26 |
27 | guard let serverUrl = URL(string: server) else {
28 | errorMessage = "Invalid URL"
29 | return
30 | }
31 |
32 | isLoading = true
33 |
34 | let request = Audiobookshelf.Request.Login(username: username, password: password)
35 | let result = await client.request(request, from: serverUrl)
36 | isLoading = false
37 | switch result {
38 | case let .success(response):
39 | successMessage = "Successfully signed in as \(response.user.username)."
40 | serverInfo.url = URL(string: server)!
41 | serverInfo.token = response.user.token
42 | router.path.append(.libraries)
43 | case let .failure(error):
44 | errorMessage = error.description
45 | }
46 | }
47 |
48 | var body: some View {
49 | VStack(spacing: 16) {
50 | if let errorMessage {
51 | Text(errorMessage)
52 | .foregroundStyle(.red)
53 | }
54 | if let successMessage {
55 | Text(successMessage)
56 | .foregroundStyle(.green)
57 | }
58 | VStack {
59 | TextField("Server address", text: $server)
60 | TextField("Username", text: $username)
61 | SecureField("Password", text: $password)
62 | }
63 | .textInputAutocapitalization(.never)
64 | .autocorrectionDisabled()
65 | Button(action: {
66 | Task {
67 | await signIn()
68 | }
69 | }) {
70 | Text("Sign in")
71 | }
72 | .buttonStyle(.borderedProminent)
73 | .disabled(isLoading || server == "" || username == "")
74 | }
75 | .textFieldStyle(.roundedBorder)
76 | .padding()
77 | .navigationTitle("AudiobookshelfKit")
78 | }
79 | }
80 |
81 | extension AudiobookshelfError: CustomStringConvertible {
82 | public var description: String {
83 | switch self {
84 | case .invalidRequest:
85 | return "Invalid request."
86 | case let .networkError(_, reason):
87 | switch reason {
88 | case let .urlSessionError(error):
89 | return "HTTP error: \(error)"
90 | case let .unacceptableStatusCode(statusCode):
91 | return "Unacceptable status code: \(statusCode)"
92 | }
93 | case let .decodingFailed(_, error):
94 | return "Decoding failed: \(error)"
95 | case .notAuthenticated:
96 | return "Not authenticated"
97 | }
98 | }
99 | }
100 |
101 | #Preview {
102 | SignInView()
103 | .environment(ServerInfo.mock)
104 | .environment(Router())
105 | }
106 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | AudiobookshelfKit is a Swift Package Manager library that provides an API client for the Audiobookshelf server. It's designed to be the foundation for Prologue's Audiobookshelf support across Apple platforms (iOS 16+, macOS 13+, watchOS 9+, tvOS 16+).
8 |
9 | ## Commands
10 |
11 | ### Building and Testing
12 | - **Build**: `swift build`
13 | - **Run tests**: `swift test`
14 | - **Run single test**: `swift test --filter `
15 | - **Build example app**: Open `Example/Example.xcodeproj` in Xcode
16 |
17 | ### Development
18 | - Use Xcode for the example app development
19 | - Swift Package Manager for library development and testing
20 |
21 | ## Architecture
22 |
23 | ### Core Client (`AudiobookshelfKit.swift`)
24 | The main `Audiobookshelf` class is a URL session-based HTTP client that:
25 | - Handles authentication via Bearer tokens or query parameters
26 | - Processes requests through a generic `ResourceRequest` protocol
27 | - Returns typed `Result` values
28 | - Uses milliseconds-since-1970 date encoding/decoding strategy
29 |
30 | ### Request System (`Requests/`)
31 | All API calls implement the `ResourceRequest` protocol:
32 | - `path`: API endpoint path
33 | - `httpMethod`: HTTP method (defaults to GET)
34 | - `queryItems`: URL query parameters
35 | - `httpBody`: Codable request body
36 | - `Response`: Associated response type
37 |
38 | New requests should be added to the `Audiobookshelf.Request` namespace and follow existing patterns.
39 |
40 | ### Models (`Models/`)
41 | Data models represent Audiobookshelf API responses:
42 | - All models are `Codable` with camelCase Swift properties
43 | - Use `@CodingKey` annotations when API uses snake_case
44 | - Optional properties should match API documentation
45 | - Models often have "Expanded" and "Minified" variants
46 |
47 | ### Testing (`Tests/`)
48 | - Tests use mock JSON responses stored in `Resources/`
49 | - `BaseTestCase` provides common testing utilities
50 | - `loadResponse()` helper loads and decodes test JSON files
51 | - Test URL: `http://192.168.0.100:32400`
52 |
53 | ### Example App (`Example/`)
54 | Demonstrates library usage with:
55 | - Environment setup (`Environment/`)
56 | - Player service (`Services/Player.swift`)
57 | - Router for navigation (`Services/Router.swift`)
58 | - SwiftUI views for library browsing
59 |
60 | ## Audiobookshelf API Guidelines
61 |
62 | ### Authentication
63 | - Bearer token authentication (from `/login` endpoint)
64 | - Token passed via Authorization header: `Authorization: Bearer `
65 | - Query parameter authentication supported for GET requests: `?token=`
66 | - Client implements both strategies via `TokenStrategy` enum
67 |
68 | ### Common Query Parameters
69 | Many endpoints support standardized pagination and filtering:
70 | - `limit`: Number of results per page
71 | - `page`: Zero-indexed page number
72 | - `sort`: Field to sort by
73 | - `desc`: Boolean for reverse sort order
74 | - `filter`: Base64 encoded filter parameters
75 |
76 | ### Response Patterns
77 | Paginated responses typically include:
78 | - `results`: Array of entities
79 | - `total`: Total result count
80 | - `limit`: Applied result limit
81 | - `page`: Current page number
82 |
83 | ### Error Handling
84 | - 200: Success
85 | - 400: Bad request
86 | - 401: Unauthorized (invalid/missing token)
87 | - 404: Resource not found
88 |
89 | ## Key Patterns
90 |
91 | - All API requests are async and return `Result` types
92 | - Authentication tokens are passed per-request, not stored in client
93 | - Models follow Audiobookshelf API naming conventions
94 | - Error handling distinguishes between request, network, and decoding failures
95 | - Pagination uses zero-indexed pages
96 | - Filters are Base64 encoded when used in query parameters
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/GetLibraryItems.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryItems.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 14/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | /// This endpoint retrieves all libraries accessible to the user.
13 | struct GetLibraryItems: ResourceRequest {
14 | public var path: String { "api/libraries/\(libraryID)/items" }
15 |
16 | public var queryItems: [URLQueryItem]? {
17 | var items = [URLQueryItem]()
18 | items.append(URLQueryItem(name: "limit", value: String(limit)))
19 | items.append(URLQueryItem(name: "page", value: String(page)))
20 | if let sort {
21 | items.append(URLQueryItem(name: "sort", value: sort))
22 | }
23 | if let desc {
24 | items.append(URLQueryItem(name: "desc", value: desc))
25 | }
26 | if let filter, let data = filter.value.data(using: .utf8) {
27 | items.append(URLQueryItem(name: "filter", value: "\(filter.group).\(data.base64EncodedString())"))
28 | }
29 | if let collapsedSeries {
30 | items.append(URLQueryItem(name: "collapsedSeries", value: String(collapsedSeries ? 1 : 0)))
31 | }
32 | if let include {
33 | items.append(URLQueryItem(name: "include", value: include))
34 | }
35 | return items
36 | }
37 |
38 | private let libraryID: String
39 | private let limit: Int
40 | private let page: Int
41 | private let sort: String?
42 | private let desc: Bool?
43 | private let filter: Filter?
44 | private let collapsedSeries: Bool?
45 | private let include: String?
46 |
47 | /// - Parameters:
48 | /// - libraryID:
49 | /// - limit: Limit the number of returned results per page. If 0, no limit will be applied.
50 | /// - page: The page number (0 indexed) to request. If there is no limit applied, then page will have no effect and all results will be returned.
51 | /// - sort: What to sort the results by. Specify the attribute to sort by using JavaScript object notation. For example, to sort by title use sort=media.metadata.title. When filtering for a series, sort can also be sequence.
52 | /// - desc: Whether to reverse the sort order. 0 for false, 1 for true.
53 | /// - collapsedSeries: Whether to collapse books in a series to a single entry. 0 for false, 1 for true.
54 | /// - include: A comma separated list of what to include with the library items. The only current option is rssfeed.
55 | public init(
56 | libraryID: String,
57 | limit: Int,
58 | page: Int,
59 | sort: String? = nil,
60 | desc: Bool? = nil,
61 | filter: Filter? = nil,
62 | collapsedSeries: Bool? = nil,
63 | include: String? = nil
64 | ) {
65 | self.libraryID = libraryID
66 | self.limit = limit
67 | self.page = page
68 | self.sort = sort
69 | self.desc = desc
70 | self.filter = filter
71 | self.collapsedSeries = collapsedSeries
72 | self.include = include
73 | }
74 |
75 | public struct Filter: Codable, Hashable, Sendable {
76 | let group: String
77 | let value: String
78 |
79 | public init(group: String, value: String) {
80 | self.group = group
81 | self.value = value
82 | }
83 | }
84 | }
85 | }
86 |
87 | public extension Audiobookshelf.Request.GetLibraryItems {
88 | struct Response: Codable, Sendable {
89 | public let results: [LibraryItem]
90 | public let total: Int
91 | public let limit: Int
92 | public let page: Int
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/LibraryItemExpanded.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LibraryItemExpanded.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct LibraryItemExpanded: Codable, Hashable, Identifiable, Sendable {
12 | /// The ID of the library item.
13 | public let id: String
14 | /// The inode of the library item.
15 | public let ino: String
16 | ///
17 | public let oldLibraryItemId: String?
18 | /// The ID of the library the item belongs to.
19 | public let libraryId: String
20 | /// The ID of the folder the library item is in.
21 | public let folderId: String
22 | /// The path of the library item on the server.
23 | public let path: String
24 | /// The path, relative to the library folder, of the library item.
25 | public let relPath: String
26 | /// Whether the library item is a single file in the root of the library folder.
27 | public let isFile: Bool
28 | /// The time (in ms since POSIX epoch) when the library item was last modified on disk.
29 | public let mtimeMs: Date
30 | /// The time (in ms since POSIX epoch) when the library item status was changed on disk.
31 | public let ctimeMs: Date
32 | /// The time (in ms since POSIX epoch) when the library item was created on disk. Will be 0 if unknown.
33 | public let birthtimeMs: Date
34 | /// The time (in ms since POSIX epoch) when the library item was added to the library.
35 | public let addedAt: Date
36 | /// The time (in ms since POSIX epoch) when the library item was last updated.
37 | public let updatedAt: Date
38 | /// The time (in ms since POSIX epoch) when the library item was last scanned. Will be null if the server has not yet scanned the library item.
39 | public let lastScan: Date?
40 | /// The version of the scanner when last scanned. Will be nil if it has not been scanned.
41 | public let scanVersion: String?
42 | /// Whether the library item was scanned and no longer exists.
43 | public let isMissing: Bool
44 | /// Whether the library item was scanned and no longer has media files.
45 | public let isInvalid: Bool
46 | /// What kind of media the library item contains.
47 | public let mediaType: MediaType
48 | /// The media of the library item.
49 | public let media: Book
50 | /// The files of the library item.
51 | public let libraryFiles: [LibraryFile]
52 | /// The size of the library item in bytes.
53 | public let size: Int64
54 | }
55 |
56 | public extension LibraryItemExpanded {
57 | struct Book: Codable, Hashable, Identifiable, Sendable {
58 | public let id: String
59 | public let libraryItemId: String
60 | public let metadata: MediaMetadata
61 | public let coverPath: String?
62 | public let tags: [String]
63 | public let audioFiles: [AudioFile]
64 | public let chapters: [Chapter]
65 | public let duration: Double
66 | public let size: Int
67 | public let tracks: [Track]
68 | // TODO:
69 | // public let ebookFile: NSNull
70 | }
71 | }
72 |
73 | public extension LibraryItemExpanded.Book {
74 | struct MediaMetadata: Codable, Hashable, Sendable {
75 | public let title: String
76 | public let titleIgnorePrefix: String?
77 | public let subtitle: String?
78 | public let authors: [AuthorMinified]
79 | public let narrators: [String]
80 | public let series: [SeriesSequence]
81 | public let genres: [String]?
82 | public let publishedYear: String?
83 | public let publishedDate: Date?
84 | public let publisher: String?
85 | public let description: String?
86 | public let isbn: String?
87 | public let asin: String?
88 | public let language: String?
89 | public let explicit: Bool?
90 | public let authorName: String?
91 | public let authorNameLF: String?
92 | public let narratorName: String?
93 | public let seriesName: String?
94 | public let abridged: Bool?
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/AudioFile.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AudioFile.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct AudioFile: Codable, Hashable, Sendable {
12 | /// The index of the audio file.
13 | public let index: Int
14 | /// The inode of the audio file.
15 | public let ino: String
16 | /// The audio file's metadata.
17 | public let metadata: LibraryFile.Metadata
18 | /// The time when the audio file was added to the library.
19 | public let addedAt: Date
20 | /// The time when the audio file was last updated.
21 | public let updatedAt: Date
22 | /// The track number of the audio file as pulled from the file's metadata. Will be null if unknown.
23 | public let trackNumFromMeta: Int?
24 | /// The disc number of the audio file as pulled from the file's metadata. Will be null if unknown.
25 | public let discNumFromMeta: Int?
26 | /// The track number of the audio file as determined from the file's name. Will be null if unknown.
27 | public let trackNumFromFilename: Int?
28 | /// The disc number of the audio file as determined from the file's name. Will be null if unknown.
29 | public let discNumFromFilename: Int?
30 | /// Whether the audio file has been manually verified by a user.
31 | public let manuallyVerified: Bool
32 | /// Whether the audio file has been marked for exclusion.
33 | public let exclude: Bool
34 | /// Any error with the audio file. Will be nil if there is none.
35 | public let error: String?
36 | /// The format of the audio file.
37 | public let format: String
38 | /// The total length (in seconds) of the audio file.
39 | public let duration: TimeInterval?
40 | /// The bit rate (in bit/s) of the audio file.
41 | public let bitRate: Int
42 | /// The language of the audio file.
43 | public let language: String?
44 | /// The codec of the audio file.
45 | public let codec: String
46 | /// The time base of the audio file.
47 | public let timeBase: String
48 | /// The number of channels the audio file has.
49 | public let channels: Int
50 | /// The layout of the audio file's channels.
51 | public let channelLayout: String?
52 | /// If the audio file is part of an audiobook, the chapters the file contains.
53 | public let chapters: [Chapter]
54 | /// The type of embedded cover art in the audio file. Will be nil if none exists.
55 | public let embeddedCoverArt: String?
56 | /// The audio metadata tags from the audio file.
57 | public let metaTags: MetaTags
58 | /// The MIME type of the audio file.
59 | public let mimeType: String
60 | }
61 |
62 | public extension AudioFile {
63 | struct MetaTags: Codable, Hashable, Sendable {
64 | public let tagAlbum: String?
65 | public let tagArtist: String?
66 | public let tagGenre: String?
67 | public let tagTitle: String?
68 | public let tagSeries: String?
69 | public let tagSeriesPart: String?
70 | public let tagTrack: String?
71 | public let tagDisc: String?
72 | public let tagSubtitle: String?
73 | public let tagAlbumArtist: String?
74 | public let tagDate: String?
75 | public let tagComposer: String?
76 | public let tagPublisher: String?
77 | public let tagComment: String?
78 | public let tagDescription: String?
79 | public let tagEncoder: String?
80 | public let tagEncodedBy: String?
81 | public let tagIsbn: String?
82 | public let tagLanguage: String?
83 | public let tagASIN: String?
84 | public let tagOverdriveMediaMarker: String?
85 | public let tagOriginalYear: String?
86 | public let tagReleaseCountry: String?
87 | public let tagReleaseType: String?
88 | public let tagReleaseStatus: String?
89 | public let tagISRC: String?
90 | public let tagMusicBrainzTrackId: String?
91 | public let tagMusicBrainzAlbumId: String?
92 | public let tagMusicBrainzAlbumArtistId: String?
93 | public let tagMusicBrainzArtistId: String?
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/login.json:
--------------------------------------------------------------------------------
1 | {
2 | "user": {
3 | "id": "a7ea108d-ed8c-4179-bedd-07cf4db866aa",
4 | "oldUserId": "root",
5 | "username": "root",
6 | "email": null,
7 | "type": "root",
8 | "token": "my-token",
9 | "mediaProgress": [
10 | {
11 | "id": "8e7ed07e-19b4-4ccf-9668-03a7ed20f1dd",
12 | "userId": "a7ea108d-ed8c-4179-bedd-07cf4db866aa",
13 | "libraryItemId": "27bec22a-0902-4226-a6ba-677a95fd993d",
14 | "episodeId": null,
15 | "mediaItemId": "66d835f9-5899-41e2-8184-2b235f6ad998",
16 | "mediaItemType": "book",
17 | "duration": 102531.286,
18 | "progress": 6.908974757226784e-05,
19 | "currentTime": 7.083860668,
20 | "isFinished": false,
21 | "hideFromContinueListening": false,
22 | "ebookLocation": null,
23 | "ebookProgress": null,
24 | "lastUpdate": 1660451502259,
25 | "startedAt": 1660451494311,
26 | "finishedAt": null
27 | },
28 | {
29 | "id": "c7d4d7ff-8be1-495f-84d6-3777e75b60e6",
30 | "userId": "a7ea108d-ed8c-4179-bedd-07cf4db866aa",
31 | "libraryItemId": "79408549-02e3-4d8f-862c-c2fa477bfb18",
32 | "episodeId": null,
33 | "mediaItemId": "38dea3ab-1f31-4541-8df7-0fb69a6926a8",
34 | "mediaItemType": "book",
35 | "duration": 0,
36 | "progress": 0.00019847837393172085,
37 | "currentTime": 14.614131646,
38 | "isFinished": false,
39 | "hideFromContinueListening": false,
40 | "ebookLocation": null,
41 | "ebookProgress": null,
42 | "lastUpdate": 1712396342047,
43 | "startedAt": 1712396336154,
44 | "finishedAt": null
45 | }
46 | ],
47 | "seriesHideFromContinueListening": [],
48 | "bookmarks": [
49 | {
50 | "libraryItemId": "8f2b0e4b-d484-47b8-b357-fbdcbc4e6458",
51 | "title": "1234",
52 | "time": 6446,
53 | "createdAt": 1723386963225
54 | }
55 | ],
56 | "isActive": true,
57 | "isLocked": false,
58 | "lastSeen": 1712674531253,
59 | "createdAt": 1650709131028,
60 | "permissions": {
61 | "download": true,
62 | "update": true,
63 | "delete": true,
64 | "upload": true,
65 | "accessAllLibraries": true,
66 | "accessAllTags": true,
67 | "accessExplicitContent": true
68 | },
69 | "librariesAccessible": [],
70 | "itemTagsSelected": [],
71 | "hasOpenIDLink": false
72 | },
73 | "userDefaultLibraryId": "5d8f9658-d52a-44f7-b52a-f7f02480f117",
74 | "serverSettings": {
75 | "id": "server-settings",
76 | "scannerFindCovers": false,
77 | "scannerCoverProvider": "google",
78 | "scannerParseSubtitle": true,
79 | "scannerPreferMatchedMetadata": false,
80 | "scannerDisableWatcher": false,
81 | "storeCoverWithItem": false,
82 | "storeMetadataWithItem": false,
83 | "metadataFileFormat": "json",
84 | "rateLimitLoginRequests": 10,
85 | "rateLimitLoginWindow": 600000,
86 | "backupSchedule": false,
87 | "backupsToKeep": 2,
88 | "maxBackupSize": 1,
89 | "loggerDailyLogsToKeep": 7,
90 | "loggerScannerLogsToKeep": 2,
91 | "homeBookshelfView": 0,
92 | "bookshelfView": 0,
93 | "podcastEpisodeSchedule": "0 * * * *",
94 | "sortingIgnorePrefix": false,
95 | "sortingPrefixes": [
96 | "the",
97 | "a"
98 | ],
99 | "chromecastEnabled": false,
100 | "dateFormat": "MM/dd/yyyy",
101 | "timeFormat": "HH:mm",
102 | "language": "en-us",
103 | "logLevel": 2,
104 | "version": "2.8.1",
105 | "buildNumber": 1,
106 | "authLoginCustomMessage": null,
107 | "authActiveAuthMethods": [
108 | "local"
109 | ],
110 | "authOpenIDIssuerURL": null,
111 | "authOpenIDAuthorizationURL": null,
112 | "authOpenIDTokenURL": null,
113 | "authOpenIDUserInfoURL": null,
114 | "authOpenIDJwksURL": null,
115 | "authOpenIDLogoutURL": null,
116 | "authOpenIDButtonText": "Login with OpenId",
117 | "authOpenIDAutoLaunch": false,
118 | "authOpenIDAutoRegister": false,
119 | "authOpenIDMatchExistingBy": null
120 | },
121 | "ereaderDevices": [],
122 | "Source": "docker"
123 | }
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Requests/SyncLocalSession.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SyncLocalSession.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 18/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Audiobookshelf.Request {
12 | struct SyncLocalSession: ResourceRequest {
13 | public let path = "api/session/local-all"
14 | public let httpMethod: String = "POST"
15 | public var httpBody: Codable? {
16 | RequestBody(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)
17 | }
18 |
19 | private let sessions: [Session]
20 |
21 | public init(_ sessions: Session) {
22 | self.sessions = [sessions]
23 | }
24 |
25 | public init(_ parameters: [Session]) {
26 | sessions = parameters
27 | }
28 | }
29 | }
30 |
31 | public extension Audiobookshelf.Request.SyncLocalSession {
32 | struct Response: Codable, Sendable {
33 | public let results: [Result]
34 | }
35 |
36 | struct RequestBody: Codable {
37 | public let sessions: [Session]
38 | public let deviceInfo: DeviceInfo?
39 | }
40 |
41 | struct Result: Codable, Hashable, Identifiable, Sendable {
42 | /// The ID of the playback session.
43 | public let id: String
44 | /// Whether the session was successfully synced.
45 | public let success: Bool
46 | /// Will only exist if success is false. The error that occurred when syncing.
47 | public let error: String?
48 | /// Will only exist if success is true. Whether the progress for the session's library item was updated.
49 | public let progressSynced: Bool?
50 | }
51 |
52 | struct Session: Codable, Sendable {
53 | /// The ID of the playback session.
54 | public let id: String
55 | /// The ID of the library item.
56 | public let libraryItemId: String
57 | /// The ID of the episode. Will be null if this playback session was started without an episode ID.
58 | public let episodeId: String?
59 | /// The title of the playing item to show to the user.
60 | public let displayTitle: String
61 | /// The author of the playing item to show to the user.
62 | public let displayAuthor: String
63 | /// The given device info when the playback session was requested.
64 | public let deviceInfo: DeviceInfo
65 | /// What play method the playback session is using. See below for values.
66 | public let playMethod: PlayMethod
67 | /// The time (in seconds) where the playback session started.
68 | public let startTime: TimeInterval
69 | /// The amount of time (in seconds) the user has spent listening using this playback session.
70 | public let timeListening: TimeInterval
71 | /// The current time (in seconds) of the playback position.
72 | public let currentTime: TimeInterval
73 | /// The time when the playback session was started.
74 | public let startedAt: Date
75 | /// The time when the playback session was last updated.
76 | public let updatedAt: Date
77 |
78 | public init(
79 | id: String,
80 | libraryItemId: String,
81 | episodeId: String?,
82 | displayTitle: String,
83 | displayAuthor: String,
84 | deviceInfo: DeviceInfo,
85 | playMethod: PlayMethod,
86 | startTime: TimeInterval,
87 | timeListening: TimeInterval,
88 | currentTime: TimeInterval,
89 | startedAt: Date,
90 | updatedAt: Date
91 | ) {
92 | self.id = id
93 | self.libraryItemId = libraryItemId
94 | self.episodeId = episodeId
95 | self.displayTitle = displayTitle
96 | self.displayAuthor = displayAuthor
97 | self.deviceInfo = deviceInfo
98 | self.playMethod = playMethod
99 | self.startTime = startTime
100 | self.timeListening = timeListening
101 | self.currentTime = currentTime
102 | self.startedAt = startedAt
103 | self.updatedAt = updatedAt
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/ServerSettings.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ServerSettings.swift
3 | // AudiobookshelfKit
4 | //
5 | // Created by Lachlan Charlick on 8/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct ServerSettings: Codable, Hashable, Sendable {
12 | /// The ID of the server settings.
13 | public let id: String
14 | /// Whether the scanner will attempt to find a cover if your audiobook does not have an embedded cover or a cover image inside the folder.
15 | /// Note: This will extend scan time.
16 | public let scannerFindCovers: Bool
17 | /// If scannerFindCovers is true, which metadata provider to use.
18 | public let scannerCoverProvider: String
19 | /// Whether to extract subtitles from audiobook folder names.
20 | /// Subtitles must be separated by -, i.e. `/audiobooks/Book Title - A Subtitle Here/` has the subtitle `A Subtitle Here`.
21 | public let scannerParseSubtitle: Bool
22 | /// Whether to use audio file ID3 meta tags instead of folder names for book details.
23 | public let scannerPreferAudioMetadata: Bool?
24 | /// Whether to use OPF file metadata instead of folder names for book details.
25 | public let scannerPreferOpfMetadata: Bool?
26 | /// Whether matched data will override item details when using Quick Match.
27 | /// By default, Quick Match will only fill in missing details.
28 | public let scannerPreferMatchedMetadata: Bool
29 | /// Whether to disable the automatic adding/updating of items when file changes are detected.
30 | /// Requires server restart for changes to take effect.
31 | public let scannerDisableWatcher: Bool
32 | /// Whether to use the custom metadata in MP3 files from Overdrive for chapter timings automatically.
33 | public let scannerPreferOverdriveMediaMarker: Bool?
34 | ///
35 | public let scannerUseSingleThreadedProber: Bool?
36 | ///
37 | public let scannerMaxThreads: Int?
38 | ///
39 | public let scannerUseTone: Bool?
40 | /// Whether to store covers in the library item's folder.
41 | /// By default, covers are stored in `/metadata/items`.
42 | /// Only one file named cover will be kept.
43 | public let storeCoverWithItem: Bool
44 | /// Whether to store metadata files in the library item's folder.
45 | /// By default, metadata files are stored in `/metadata/items`.
46 | /// Uses the `.abs` file extension.
47 | public let storeMetadataWithItem: Bool
48 | /// Must be either `json` or `abs`.
49 | public let metadataFileFormat: MetatadataFileFormat
50 | /// The maximum number of login requests per `rateLimitLoginWindow`.
51 | public let rateLimitLoginRequests: Int
52 | /// The length (in ms) of each login rate limit window.
53 | public let rateLimitLoginWindow: Int
54 | /// The cron expression for when to do automatic backups.
55 | // TODO: This seems to be a string OR boolean, but the API docs don't specify.
56 | // public let backupSchedule: String
57 | /// The number of backups to keep.
58 | public let backupsToKeep: Int
59 | /// The maximum backup size (in GB) before they fail, a safeguard against misconfiguration.
60 | public let maxBackupSize: Int
61 | /// Whether backups should include library item covers and author images located in metadata.
62 | // public let backupMetadataCovers: Bool
63 | /// The number of daily logs to keep.
64 | public let loggerDailyLogsToKeep: Int
65 | /// The number of scanner logs to keep.
66 | public let loggerScannerLogsToKeep: Int
67 | /// Whether the home page should use a skeuomorphic design with wooden shelves.
68 | public let homeBookshelfView: Int
69 | /// Whether other bookshelf pages should use a skeuomorphic design with wooden shelves.
70 | public let bookshelfView: Int
71 | /// Whether to ignore prefixes when sorting.
72 | public let sortingIgnorePrefix: Bool
73 | /// If `sortingIgnorePrefix` is true, what prefixes to ignore.
74 | public let sortingPrefixes: [String]
75 | /// Whether to enable streaming to Chromecast devices.
76 | public let chromecastEnabled: Bool
77 | /// What date format to use.
78 | /// Options are `MM/dd/yyyy`, `dd/MM/yyyy`, `dd.MM.yyyy`, `yyyy-MM-dd`, `MMM do, yyyy`, `MMMM do, yyyy`, `dd MMM yyyy`, or `dd MMMM yyyy`.
79 | public let dateFormat: String
80 | /// What time format to use.
81 | public let timeFormat: String
82 | /// The default server language.
83 | public let language: String
84 | /// What log level the server should use when logging.
85 | /// 1 for debug, 2 for info, or 3 for warnings.
86 | public let logLevel: Int
87 | /// The server's version.
88 | public let version: String
89 | }
90 |
91 | public enum MetatadataFileFormat: String, Codable, Sendable {
92 | case json
93 | case abs
94 | }
95 |
--------------------------------------------------------------------------------
/Sources/AudiobookshelfKit/Models/LibraryItem.swift:
--------------------------------------------------------------------------------
1 |
2 | //
3 | // LibraryItem.swift
4 | // AudiobookshelfKit
5 | //
6 | // Created by Lachlan Charlick on 14/4/24.
7 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
8 | //
9 |
10 | import Foundation
11 |
12 | public struct LibraryItem: Codable, Hashable, Identifiable, Sendable {
13 | /// The ID of the library item.
14 | public let id: String
15 | /// The inode of the library item.
16 | public let ino: String
17 | ///
18 | public let oldLibraryItemId: String?
19 | /// The ID of the library the item belongs to.
20 | public let libraryId: String
21 | /// The ID of the folder the library item is in.
22 | public let folderId: String
23 | /// The path of the library item on the server.
24 | public let path: String
25 | /// The path, relative to the library folder, of the library item.
26 | public let relPath: String
27 | /// Whether the library item is a single file in the root of the library folder.
28 | public let isFile: Bool
29 | /// The time (in ms since POSIX epoch) when the library item was last modified on disk.
30 | public let mtimeMs: Date
31 | /// The time (in ms since POSIX epoch) when the library item status was changed on disk.
32 | public let ctimeMs: Date
33 | /// The time (in ms since POSIX epoch) when the library item was created on disk. Will be 0 if unknown.
34 | public let birthtimeMs: Date
35 | /// The time (in ms since POSIX epoch) when the library item was added to the library.
36 | public let addedAt: Date
37 | /// The time (in ms since POSIX epoch) when the library item was last updated.
38 | public let updatedAt: Date
39 | /// Whether the library item was scanned and no longer exists.
40 | public let isMissing: Bool
41 | /// Whether the library item was scanned and no longer has media files.
42 | public let isInvalid: Bool
43 | /// What kind of media the library item contains.
44 | public let mediaType: MediaType
45 | /// The media of the library item.
46 | public let media: Book
47 | /// The number of files the library item contains.
48 | public let numFiles: Int?
49 | /// The size of the library item in bytes.
50 | public let size: Int64
51 | }
52 |
53 | public extension LibraryItem {
54 | struct Book: Codable, Hashable, Identifiable, Sendable {
55 | /// The ID of the book.
56 | public let id: String
57 | /// The book's metadata.
58 | public let metadata: Metadata
59 | /// The absolute path on the server of the cover file. Will be null if there is no cover.
60 | public let coverPath: String?
61 | /// The book's tags.
62 | public let tags: [String]
63 | /// The number of tracks the book contains.
64 | public let numTracks: Int?
65 | /// The number of audio files the book contains.
66 | public let numAudioFiles: Int?
67 | /// The number of chapters the book contains.
68 | public let numChapters: Int?
69 | /// The duration of the book in seconds.
70 | public let duration: TimeInterval
71 | /// The size of the book in bytes.
72 | public let size: Int64
73 | }
74 | }
75 |
76 | public extension LibraryItem.Book {
77 | struct Metadata: Codable, Hashable, Sendable {
78 | /// The title of the book.
79 | public let title: String
80 | /// The sort title of the book.
81 | public let titleIgnorePrefix: String?
82 | /// The subtitle of the book.
83 | public let subtitle: String?
84 | /// The authors of the book.
85 | public let authorName: String
86 | /// The author's last name, first name.
87 | public let authorNameLF: String
88 | /// The narrators of the audiobook.
89 | public let narratorName: String
90 | /// The series the book belongs to.
91 | public let seriesName: String?
92 | /// The genres of the book.
93 | public let genres: [String]?
94 | /// The year the book was published.
95 | public let publishedYear: String?
96 | /// The date the book was published.
97 | public let publishedDate: Date?
98 | /// The publisher of the book.
99 | public let publisher: String?
100 | /// A description for the book.
101 | public let description: String?
102 | /// The ISBN of the book.
103 | public let isbn: String?
104 | /// The ASIN of the book.
105 | public let asin: String?
106 | /// The language of the book.
107 | public let language: String?
108 | /// Whether the book has been marked as explicit.
109 | public let explicit: Bool
110 | /// Whether the book is abridged.
111 | public let abridged: Bool
112 | /// The series the book belongs to, if any.
113 | /// Only available when filtering by series.
114 | public let series: SeriesSequence?
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetLibraryPlaylistsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryPlaylistsTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetLibraryPlaylistsTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetLibraryPlaylists(
16 | libraryID: "my-library"
17 | )
18 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
19 |
20 | let data = RequestData(request: request)
21 |
22 | #expect(data.baseURL == testURL.appendingPathComponent("/api/libraries/my-library/playlists"))
23 | #expect(data.headers == [
24 | "Accept": "application/json",
25 | "Authorization": "Bearer my-token",
26 | ])
27 |
28 | #expect(data.queryItems == [
29 | "limit": "0",
30 | "page": "0",
31 | ])
32 | }
33 |
34 | @Test func request_withPagination() throws {
35 | let request = try Audiobookshelf.Request.GetLibraryPlaylists(
36 | libraryID: "my-library",
37 | limit: 10,
38 | page: 2
39 | )
40 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
41 |
42 | let data = RequestData(request: request)
43 |
44 | #expect(data.baseURL == testURL.appendingPathComponent("/api/libraries/my-library/playlists"))
45 | #expect(data.headers == [
46 | "Accept": "application/json",
47 | "Authorization": "Bearer my-token",
48 | ])
49 |
50 | #expect(data.queryItems == [
51 | "limit": "10",
52 | "page": "2",
53 | ])
54 | }
55 |
56 | @Test func response() throws {
57 | let response = try loadResponse(
58 | "library_playlists",
59 | for: Audiobookshelf.Request.GetLibraryPlaylists.self
60 | )
61 |
62 | #expect(response.total == 1)
63 | #expect(response.limit == 0)
64 | #expect(response.page == 0)
65 | #expect(response.results.count == 1)
66 |
67 | let playlist = response.results[0]
68 | #expect(playlist.id == "pl_qbwet64998s5ra6dcu")
69 | #expect(playlist.libraryId == "lib_c1u6t4p45c35rf0nzd")
70 | #expect(playlist.userId == "root")
71 | #expect(playlist.name == "Favorites")
72 | #expect(playlist.description == nil)
73 | #expect(playlist.coverPath == nil)
74 | #expect(playlist.lastUpdate == Date(timeIntervalSince1970: 1_669_623_431_313 / 1000))
75 | #expect(playlist.createdAt == Date(timeIntervalSince1970: 1_669_623_431_313 / 1000))
76 |
77 | #expect(playlist.items.count == 1)
78 | let playlistItem = playlist.items[0]
79 | #expect(playlistItem.libraryItemId == "li_8gch9ve09orgn4fdz8")
80 | #expect(playlistItem.episodeId == nil)
81 |
82 | let libraryItem = try #require(playlistItem.libraryItem)
83 | #expect(libraryItem.id == "li_8gch9ve09orgn4fdz8")
84 | #expect(libraryItem.ino == "649641337522215266")
85 | #expect(libraryItem.libraryId == "lib_c1u6t4p45c35rf0nzd")
86 | #expect(libraryItem.folderId == "fol_bev1zuxhb0j0s1wehr")
87 | #expect(libraryItem.path == "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule")
88 | #expect(libraryItem.relPath == "Terry Goodkind/Sword of Truth/Wizards First Rule")
89 | #expect(libraryItem.isFile == false)
90 | #expect(libraryItem.isMissing == false)
91 | #expect(libraryItem.isInvalid == false)
92 | #expect(libraryItem.mediaType == .book)
93 |
94 | #expect(libraryItem.media.metadata.title == "Wizards First Rule")
95 | #expect(libraryItem.media.metadata.titleIgnorePrefix == "Wizards First Rule")
96 | #expect(libraryItem.media.metadata.subtitle == nil)
97 | #expect(libraryItem.media.metadata.authorName == "Terry Goodkind")
98 | #expect(libraryItem.media.metadata.authorNameLF == "Goodkind, Terry")
99 | #expect(libraryItem.media.metadata.narratorName == "Sam Tsoutsouvas")
100 | #expect(libraryItem.media.metadata.seriesName == "Sword of Truth")
101 | #expect(libraryItem.media.metadata.genres == ["Fantasy"])
102 | #expect(libraryItem.media.metadata.publishedYear == "2008")
103 | #expect(libraryItem.media.metadata.publisher == "Brilliance Audio")
104 | #expect(libraryItem.media.metadata.asin == "B002V0QK4C")
105 | #expect(libraryItem.media.metadata.explicit == false)
106 | #expect(libraryItem.media.metadata.abridged == false)
107 |
108 | #expect(libraryItem.media.coverPath == "/metadata/items/li_8gch9ve09orgn4fdz8/cover.jpg")
109 | #expect(libraryItem.media.tags == ["Fantasy", "Epic"])
110 | #expect(libraryItem.media.duration == 33854.905)
111 | #expect(libraryItem.media.size == 268_824_228)
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetLibraryItemsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetLibraryItemsTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 14/4/24.
6 | // Copyright © 2024 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetLibraryItemsTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetLibraryItems(
16 | libraryID: "my-library",
17 | limit: 5,
18 | page: 0
19 | )
20 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
21 |
22 | let data = RequestData(request: request)
23 |
24 | #expect(data.baseURL == testURL.appendingPathComponent("/api/libraries/my-library/items"))
25 | #expect(data.headers == [
26 | "Accept": "application/json",
27 | "Authorization": "Bearer my-token",
28 | ])
29 |
30 | #expect(data.queryItems == [
31 | "limit": "5",
32 | "page": "0",
33 | ])
34 | }
35 |
36 | @Test func request_optionalParameters() throws {
37 | let request = try Audiobookshelf.Request.GetLibraryItems(
38 | libraryID: "my-library",
39 | limit: 5,
40 | page: 1,
41 | sort: "media.metadata.title",
42 | desc: true,
43 | collapsedSeries: true,
44 | include: "rssfeed"
45 | )
46 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
47 |
48 | let data = RequestData(request: request)
49 |
50 | #expect(data.baseURL == testURL.appendingPathComponent("/api/libraries/my-library/items"))
51 | #expect(data.headers == [
52 | "Accept": "application/json",
53 | "Authorization": "Bearer my-token",
54 | ])
55 |
56 | #expect(data.queryItems == [
57 | "limit": "5",
58 | "page": "1",
59 | "sort": "media.metadata.title",
60 | "desc": "1",
61 | "collapsedSeries": "1",
62 | "include": "rssfeed",
63 | ])
64 | }
65 |
66 | @Test func response() throws {
67 | let response = try loadResponse(
68 | "library_items",
69 | for: Audiobookshelf.Request.GetLibraryItems.self
70 | )
71 |
72 | #expect(response.results.count == 22)
73 |
74 | let firstItem = response.results[0]
75 | #expect(firstItem.id == "bc0719db-2124-4ba2-9860-1b729cbfcc6e")
76 | #expect(firstItem.ino == "147827")
77 | #expect(firstItem.oldLibraryItemId == nil)
78 | #expect(firstItem.libraryId == "71288985-7f00-4a29-b671-836edde7d3a4")
79 | #expect(firstItem.folderId == "107b1c26-1f79-4118-acc5-531cc41a9f80")
80 | #expect(firstItem.path == "/Volumes/media/audiobooks_screenshots/JRR Tolkien/The Hobbit")
81 | #expect(firstItem.relPath == "JRR Tolkien/The Hobbit")
82 | #expect(firstItem.isFile == false)
83 | #expect(firstItem.mtimeMs == Date(timeIntervalSince1970: 1_596_375_037_000 / 1000))
84 | #expect(firstItem.ctimeMs == Date(timeIntervalSince1970: 1_596_375_037_000 / 1000))
85 | #expect(firstItem.birthtimeMs == Date(timeIntervalSince1970: 1_596_368_362_000 / 1000))
86 | #expect(firstItem.addedAt == Date(timeIntervalSince1970: 1_654_499_247_059 / 1000))
87 | #expect(firstItem.updatedAt == Date(timeIntervalSince1970: 1_713_354_157_355 / 1000))
88 | #expect(firstItem.isMissing == false)
89 | #expect(firstItem.isInvalid == false)
90 | #expect(firstItem.mediaType == .book)
91 | #expect(firstItem.media.id == "dbb1da04-8049-4fba-b9b6-bca66e5c9d6a")
92 | #expect(firstItem.media.metadata.title == "The Hobbit")
93 | #expect(firstItem.media.metadata.titleIgnorePrefix == "Hobbit, The")
94 | #expect(firstItem.media.metadata.subtitle == nil)
95 | #expect(firstItem.media.metadata.authorName == "JRR Tolkien")
96 | #expect(firstItem.media.metadata.authorNameLF == "Tolkien, JRR")
97 | #expect(firstItem.media.metadata.narratorName == "Rob Inglis")
98 | #expect(firstItem.media.metadata.seriesName == "")
99 | #expect(firstItem.media.metadata.genres == ["Fantasy"])
100 | #expect(firstItem.media.metadata.publishedYear == "1937")
101 | #expect(firstItem.media.metadata.publishedDate == nil)
102 | #expect(firstItem.media.metadata.publisher == nil)
103 | #expect(firstItem.media.metadata.description == nil)
104 | #expect(firstItem.media.metadata.isbn == nil)
105 | #expect(firstItem.media.metadata.asin == nil)
106 | #expect(firstItem.media.metadata.language == nil)
107 | #expect(firstItem.media.metadata.explicit == false)
108 | #expect(firstItem.media.metadata.abridged == false)
109 | #expect(firstItem.media.coverPath == "/Users/lcharlick/git/audiobookshelf/metadata/items/li_3xi3eu3m5ey3gcj45x/cover.png")
110 | #expect(firstItem.media.tags == [])
111 | #expect(firstItem.media.numTracks == 1)
112 | #expect(firstItem.media.numAudioFiles == 1)
113 | #expect(firstItem.media.numChapters == 19)
114 | #expect(firstItem.media.duration == 39720.024)
115 | #expect(firstItem.media.size == 18_226_998)
116 | #expect(firstItem.numFiles == 1)
117 | #expect(firstItem.size == 18_226_998)
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/SyncLocalSessionTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SyncLocalSessionTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 25/5/2025.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct SyncLocalSessionTests {
14 | @Test func request_singleSession() throws {
15 | let deviceInfo = DeviceInfo(
16 | deviceId: "device123",
17 | manufacturer: "Apple",
18 | model: "iPhone",
19 | clientName: "AudiobookshelfKit",
20 | clientVersion: "1.0.0"
21 | )
22 |
23 | let session = Audiobookshelf.Request.SyncLocalSession.Session(
24 | id: "play_session_1",
25 | libraryItemId: "li_8gch9ve09orgn4fdz8",
26 | episodeId: nil,
27 | displayTitle: "Wizards First Rule",
28 | displayAuthor: "Terry Goodkind",
29 | deviceInfo: deviceInfo,
30 | playMethod: .directPlay,
31 | startTime: 0,
32 | timeListening: 300,
33 | currentTime: 300,
34 | startedAt: Date(timeIntervalSince1970: 1_650_621_073_750 / 1000),
35 | updatedAt: Date(timeIntervalSince1970: 1_650_621_373_750 / 1000)
36 | )
37 |
38 | let request = try Audiobookshelf.Request.SyncLocalSession(session)
39 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
40 |
41 | let data = RequestData(request: request)
42 |
43 | #expect(data.baseURL == testURL.appendingPathComponent("/api/session/local-all"))
44 | #expect(data.httpMethod == "POST")
45 | #expect(data.headers == [
46 | "Accept": "application/json",
47 | "Authorization": "Bearer my-token",
48 | "Content-Type": "application/json",
49 | ])
50 |
51 | let body = try JSONDecoder().decode(
52 | Audiobookshelf.Request.SyncLocalSession.RequestBody.self,
53 | from: data.rawHttpBody!
54 | )
55 | #expect(body.sessions.count == 1)
56 | #expect(body.sessions[0].id == "play_session_1")
57 | #expect(body.sessions[0].libraryItemId == "li_8gch9ve09orgn4fdz8")
58 | #expect(body.deviceInfo?.deviceId == "device123")
59 | }
60 |
61 | @Test func request_multipleSessions() throws {
62 | let deviceInfo = DeviceInfo(
63 | deviceId: "device123",
64 | manufacturer: "Apple",
65 | model: "iPhone",
66 | clientName: "AudiobookshelfKit",
67 | clientVersion: "1.0.0"
68 | )
69 |
70 | let sessions = [
71 | Audiobookshelf.Request.SyncLocalSession.Session(
72 | id: "play_session_1",
73 | libraryItemId: "li_8gch9ve09orgn4fdz8",
74 | episodeId: nil,
75 | displayTitle: "Wizards First Rule",
76 | displayAuthor: "Terry Goodkind",
77 | deviceInfo: deviceInfo,
78 | playMethod: .directPlay,
79 | startTime: 0,
80 | timeListening: 300,
81 | currentTime: 300,
82 | startedAt: Date(timeIntervalSince1970: 1_650_621_073_750 / 1000),
83 | updatedAt: Date(timeIntervalSince1970: 1_650_621_373_750 / 1000)
84 | ),
85 | Audiobookshelf.Request.SyncLocalSession.Session(
86 | id: "play_session_2",
87 | libraryItemId: "li_another_item",
88 | episodeId: "ep_123",
89 | displayTitle: "Podcast Episode",
90 | displayAuthor: "Podcast Author",
91 | deviceInfo: deviceInfo,
92 | playMethod: .transcode,
93 | startTime: 100,
94 | timeListening: 200,
95 | currentTime: 300,
96 | startedAt: Date(timeIntervalSince1970: 1_650_621_173_750 / 1000),
97 | updatedAt: Date(timeIntervalSince1970: 1_650_621_473_750 / 1000)
98 | ),
99 | ]
100 |
101 | let request = try Audiobookshelf.Request.SyncLocalSession(sessions)
102 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
103 |
104 | let data = RequestData(request: request)
105 |
106 | let body = try JSONDecoder().decode(
107 | Audiobookshelf.Request.SyncLocalSession.RequestBody.self,
108 | from: data.rawHttpBody!
109 | )
110 | #expect(body.sessions.count == 2)
111 | #expect(body.sessions[0].id == "play_session_1")
112 | #expect(body.sessions[1].id == "play_session_2")
113 | #expect(body.sessions[1].episodeId == "ep_123")
114 | }
115 |
116 | @Test func response() throws {
117 | let response = try loadResponse(
118 | "sync_local_session",
119 | for: Audiobookshelf.Request.SyncLocalSession.self
120 | )
121 |
122 | #expect(response.results.count == 2)
123 |
124 | let result1 = response.results[0]
125 | #expect(result1.id == "play_session_1")
126 | #expect(result1.success == true)
127 | #expect(result1.error == nil)
128 | #expect(result1.progressSynced == true)
129 |
130 | let result2 = response.results[1]
131 | #expect(result2.id == "play_session_2")
132 | #expect(result2.success == false)
133 | #expect(result2.error == "Session not found")
134 | #expect(result2.progressSynced == nil)
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Requests/GetUserPlaybackSessionsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetUserPlaybackSessionsTests.swift
3 | // AudiobookshelfKitTests
4 | //
5 | // Created by Lachlan Charlick on 18/4/25.
6 | // Copyright © 2025 Lachlan Charlick. All rights reserved.
7 | //
8 |
9 | import AudiobookshelfKit
10 | import Foundation
11 | import Testing
12 |
13 | struct GetUserSessionsTests {
14 | @Test func request() throws {
15 | let request = try Audiobookshelf.Request.GetUserPlaybackSessions(page: 1, itemsPerPage: 10)
16 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
17 |
18 | let data = RequestData(request: request)
19 |
20 | #expect(data.baseURL == testURL.appendingPathComponent("api/me/listening-sessions"))
21 | #expect(data.headers == [
22 | "Accept": "application/json",
23 | "Authorization": "Bearer my-token",
24 | ])
25 | #expect(data.queryItems == [
26 | "page": "1",
27 | "itemsPerPage": "10",
28 | ])
29 | }
30 |
31 | @Test func request_withPaging() throws {
32 | let request = try Audiobookshelf.Request.GetUserPlaybackSessions(page: 2, itemsPerPage: 10)
33 | .asURLRequest(from: testURL, using: "my-token", customHeaders: [:])
34 |
35 | let data = RequestData(request: request)
36 |
37 | #expect(data.baseURL == testURL.appendingPathComponent("api/me/listening-sessions"))
38 | #expect(data.headers == [
39 | "Accept": "application/json",
40 | "Authorization": "Bearer my-token",
41 | ])
42 | #expect(data.queryItems == [
43 | "page": "2",
44 | "itemsPerPage": "10",
45 | ])
46 | }
47 |
48 | @Test func response() throws {
49 | let data = """
50 | {
51 | "sessions": [
52 | {
53 | "id": "123e4567-e89b-12d3-a456-426614174000",
54 | "userId": "user123",
55 | "libraryId": "lib456",
56 | "libraryItemId": "lib123",
57 | "episodeId": null,
58 | "mediaType": "book",
59 | "mediaMetadata": {
60 | "title": "Test Book",
61 | "titleIgnorePrefix": null,
62 | "subtitle": null,
63 | "authorName": "Test Author",
64 | "authorNameLF": "Author, Test",
65 | "narratorName": "Test Narrator",
66 | "seriesName": null,
67 | "genres": [],
68 | "publishedYear": null,
69 | "publishedDate": null,
70 | "publisher": null,
71 | "description": null,
72 | "isbn": null,
73 | "asin": null,
74 | "language": null,
75 | "explicit": false,
76 | "abridged": false
77 | },
78 | "chapters": [],
79 | "displayTitle": "Test Book",
80 | "displayAuthor": "Test Author",
81 | "coverPath": "/covers/lib123.jpg",
82 | "duration": 3600,
83 | "playMethod": 0,
84 | "mediaPlayer": "iOS",
85 | "deviceInfo": {
86 | "id": "dev123",
87 | "userId": "user123",
88 | "deviceId": "device123",
89 | "ipAddress": "192.168.1.1",
90 | "browserName": null,
91 | "browserVersion": null,
92 | "osName": "iOS",
93 | "osVersion": "17.0",
94 | "deviceName": "iPhone",
95 | "deviceType": "mobile",
96 | "manufacturer": "Apple",
97 | "model": "iPhone",
98 | "sdkVersion": null,
99 | "clientName": "Test Client",
100 | "clientVersion": "1.0.0"
101 | },
102 | "serverVersion": "2.5.0",
103 | "date": "2024-03-19",
104 | "dayOfWeek": "Tuesday",
105 | "timeListening": 300,
106 | "startTime": 0,
107 | "currentTime": 300,
108 | "startedAt": 1710864000000,
109 | "updatedAt": 1710864300000
110 | }
111 | ],
112 | "total": 1,
113 | "numPages": 1,
114 | "itemsPerPage": 10
115 | }
116 | """.data(using: .utf8)!
117 |
118 | let response = try Audiobookshelf.Request.GetUserPlaybackSessions.response(from: data)
119 |
120 | #expect(response.sessions.count == 1)
121 | #expect(response.total == 1)
122 | #expect(response.numPages == 1)
123 | #expect(response.itemsPerPage == 10)
124 |
125 | let session = response.sessions[0]
126 | #expect(session.id == "123e4567-e89b-12d3-a456-426614174000")
127 | #expect(session.libraryItemId == "lib123")
128 | #expect(session.episodeId == nil)
129 | #expect(session.displayTitle == "Test Book")
130 | #expect(session.displayAuthor == "Test Author")
131 | #expect(session.deviceInfo.deviceId == "device123")
132 | #expect(session.deviceInfo.manufacturer == "Apple")
133 | #expect(session.deviceInfo.model == "iPhone")
134 | #expect(session.deviceInfo.clientName == "Test Client")
135 | #expect(session.deviceInfo.clientVersion == "1.0.0")
136 | #expect(session.startTime == 0)
137 | #expect(session.timeListening == 300)
138 | #expect(session.currentTime == 300)
139 | #expect(session.startedAt?.timeIntervalSince1970 == 1_710_864_000)
140 | #expect(session.updatedAt?.timeIntervalSince1970 == 1_710_864_300)
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/Tests/AudiobookshelfKitTests/Resources/author.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "aut_z3leimgybl7uf3y4ab",
3 | "asin": "B000APZOQA",
4 | "name": "Terry Goodkind",
5 | "description": "Terry Goodkind is a #1 New York Times Bestselling Author and creator of the critically acclaimed masterwork, 'The Sword of Truth'. He has written 30+ major, bestselling novels, has been published in more than 20 languages world-wide, and has sold more than 26 Million books. 'The Sword of Truth' is a revered literary tour de force, comprised of 17 volumes, borne from over 25 years of dedicated writing.",
6 | "imagePath": "/metadata/authors/aut_z3leimgybl7uf3y4ab/image.jpg",
7 | "addedAt": 1650621073750,
8 | "updatedAt": 1650621073750,
9 | "libraryId": "lib_c1u6t4p45c35rf0nzd",
10 | "libraryItems": [
11 | {
12 | "id": "li_8gch9ve09orgn4fdz8",
13 | "ino": "649641337522215266",
14 | "oldLibraryItemId": null,
15 | "libraryId": "lib_c1u6t4p45c35rf0nzd",
16 | "folderId": "fol_bev1zuxhb0j0s1wehr",
17 | "path": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule",
18 | "relPath": "Terry Goodkind/Sword of Truth/Wizards First Rule",
19 | "isFile": false,
20 | "mtimeMs": 1650621074299,
21 | "ctimeMs": 1650621074299,
22 | "birthtimeMs": 0,
23 | "addedAt": 1650621073750,
24 | "updatedAt": 1650621110769,
25 | "lastScan": 1651830827825,
26 | "scanVersion": "2.0.21",
27 | "isMissing": false,
28 | "isInvalid": false,
29 | "mediaType": "book",
30 | "media": {
31 | "id": "book_1",
32 | "libraryItemId": "li_8gch9ve09orgn4fdz8",
33 | "metadata": {
34 | "title": "Wizards First Rule",
35 | "titleIgnorePrefix": "Wizards First Rule",
36 | "subtitle": null,
37 | "authors": [
38 | {
39 | "id": "aut_z3leimgybl7uf3y4ab",
40 | "name": "Terry Goodkind"
41 | }
42 | ],
43 | "narrators": ["Sam Tsoutsouvas"],
44 | "series": [
45 | {
46 | "id": "ser_cabkj4jeu8be3rap4g",
47 | "name": "Sword of Truth",
48 | "sequence": "1"
49 | }
50 | ],
51 | "genres": ["Fantasy"],
52 | "publishedYear": "2008",
53 | "publishedDate": null,
54 | "publisher": "Brilliance Audio",
55 | "description": "The masterpiece that started Terry Goodkind's New York Times bestselling epic Sword of Truth",
56 | "isbn": null,
57 | "asin": "B002V0QK4C",
58 | "language": null,
59 | "explicit": false,
60 | "authorName": "Terry Goodkind",
61 | "authorNameLF": "Goodkind, Terry",
62 | "narratorName": "Sam Tsoutsouvas",
63 | "seriesName": "Sword of Truth",
64 | "abridged": false
65 | },
66 | "coverPath": "/metadata/items/li_8gch9ve09orgn4fdz8/cover.jpg",
67 | "tags": [],
68 | "audioFiles": [
69 | {
70 | "index": 1,
71 | "ino": "649644248522215280",
72 | "metadata": {
73 | "filename": "Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
74 | "ext": ".mp3",
75 | "path": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
76 | "relPath": "Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
77 | "size": 48037888,
78 | "mtimeMs": 1632223180278,
79 | "ctimeMs": 1645978261001,
80 | "birthtimeMs": 0
81 | },
82 | "addedAt": 1650621074131,
83 | "updatedAt": 1651830828023,
84 | "trackNumFromMeta": 1,
85 | "discNumFromMeta": null,
86 | "trackNumFromFilename": 1,
87 | "discNumFromFilename": null,
88 | "manuallyVerified": false,
89 | "exclude": false,
90 | "error": null,
91 | "format": "MP2/3 (MPEG audio layer 2/3)",
92 | "duration": 6004.6675,
93 | "bitRate": 64000,
94 | "language": null,
95 | "codec": "mp3",
96 | "timeBase": "1/14112000",
97 | "channels": 2,
98 | "channelLayout": "stereo",
99 | "chapters": [],
100 | "embeddedCoverArt": null,
101 | "metaTags": {
102 | "tagAlbum": "SOT Bk01 - Wizards First Rule",
103 | "tagArtist": "Terry Goodkind",
104 | "tagGenre": "Audiobook Fantasy",
105 | "tagTitle": "Terry Goodkind - SOT Bk01 - Wizards First Rule 01",
106 | "tagTrack": "01/20",
107 | "tagAlbumArtist": "Terry Goodkind",
108 | "tagComposer": "Terry Goodkind"
109 | },
110 | "mimeType": "audio/mpeg"
111 | }
112 | ],
113 | "chapters": [],
114 | "duration": 33854.905,
115 | "size": 268824228,
116 | "tracks": [
117 | {
118 | "index": 1,
119 | "startOffset": 0,
120 | "duration": 6004.6675,
121 | "title": "Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
122 | "contentUrl": "/api/items/li_8gch9ve09orgn4fdz8/file/649644248522215280",
123 | "mimeType": "audio/mpeg",
124 | "codec": "mp3",
125 | "metadata": {
126 | "filename": "Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
127 | "ext": ".mp3",
128 | "path": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
129 | "relPath": "Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
130 | "size": 48037888,
131 | "mtimeMs": 1632223180278,
132 | "ctimeMs": 1645978261001,
133 | "birthtimeMs": 0
134 | }
135 | }
136 | ],
137 | "ebookFile": null,
138 | "numTracks": 1,
139 | "numAudioFiles": 1,
140 | "numChapters": 0
141 | },
142 | "libraryFiles": [
143 | {
144 | "ino": "649644248522215280",
145 | "metadata": {
146 | "filename": "Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
147 | "ext": ".mp3",
148 | "path": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule/Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
149 | "relPath": "Terry Goodkind - SOT Bk01 - Wizards First Rule 01.mp3",
150 | "size": 48037888,
151 | "mtimeMs": 1632223180278,
152 | "ctimeMs": 1645978261001,
153 | "birthtimeMs": 0
154 | },
155 | "isSupplementary": null,
156 | "addedAt": 1650621074130,
157 | "updatedAt": 1650621074130,
158 | "fileType": "audio"
159 | }
160 | ],
161 | "size": 268824228,
162 | "numFiles": 1
163 | }
164 | ]
165 | }
--------------------------------------------------------------------------------