├── .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 | } --------------------------------------------------------------------------------