Grundcode AudiobookshelfClient

This commit is contained in:
Scarriffle
2026-05-14 19:24:52 +02:00
parent 7d0bc6466a
commit 652cfc4cf4
6 changed files with 495 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
import Foundation
class ABSService: ObservableObject {
static let shared = ABSService()
func fetchLibraries(serverURL: String, token: String) async throws -> [Library] {
let url = URL(string: "\(serverURL)/api/libraries")!
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(LibrariesResponse.self, from: data)
return response.libraries
}
func fetchBooks(serverURL: String, token: String, libraryId: String) async throws -> [AudiobookItem] {
let url = URL(string: "\(serverURL)/api/libraries/\(libraryId)/items?limit=100")!
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(LibraryItemsResponse.self, from: data)
return response.results.compactMap { item in
AudiobookItem(
id: item.id,
title: item.media.metadata.title ?? "Unbekannt",
author: item.media.metadata.authorName ?? "Unbekannt",
coverURL: item.media.coverPath != nil ? "\(serverURL)/api/items/\(item.id)/cover" : nil,
duration: item.media.duration ?? 0,
mediaFiles: item.media.audioFiles?.map {
AudiobookItem.MediaFile(ino: $0.ino, name: $0.metadata.filename, path: $0.metadata.path)
} ?? []
)
}
}
func fetchProgress(serverURL: String, token: String, itemId: String) async throws -> LibraryProgress? {
let url = URL(string: "\(serverURL)/api/me/progress/\(itemId)")!
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(LibraryProgress.self, from: data)
}
func updateProgress(serverURL: String, token: String, itemId: String, currentTime: Double, duration: Double) async {
guard let url = URL(string: "\(serverURL)/api/me/progress/\(itemId)") else { return }
var request = URLRequest(url: url)
request.httpMethod = "PATCH"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"currentTime": currentTime,
"duration": duration,
"isFinished": currentTime >= duration - 5
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
try? await URLSession.shared.data(for: request)
}
}
// MARK: - API Response Models
struct LibrariesResponse: Codable {
let libraries: [Library]
}
struct LibraryItemsResponse: Codable {
let results: [RawLibraryItem]
}
struct RawLibraryItem: Codable {
let id: String
let media: RawMedia
}
struct RawMedia: Codable {
let metadata: RawMetadata
let coverPath: String?
let duration: Double?
let audioFiles: [RawAudioFile]?
}
struct RawMetadata: Codable {
let title: String?
let authorName: String?
}
struct RawAudioFile: Codable {
let ino: String
let metadata: RawFileMetadata
}
struct RawFileMetadata: Codable {
let filename: String
let path: String
}

View File

View File

@@ -0,0 +1,72 @@
//
// LibraryView.swift
// Audiobookshelf swift
//
// Created by Guido Schmit on 13.05.2026.
//
import SwiftUI
struct LibraryView: View {
@ObservedObject var authManager: AuthManager
@State private var books: [AudiobookItem] = []
@State private var isLoading = true
@State private var errorMessage = ""
var body: some View {
NavigationView {
Group {
if isLoading {
ProgressView("Lädt Bibliothek...")
} else if !errorMessage.isEmpty {
Text(errorMessage).foregroundColor(.red)
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) {
ForEach(books) { book in
BookCard(book: book, authManager: authManager)
}
}
.padding()
}
}
}
.navigationTitle("Meine Bibliothek")
.toolbar {
Button("Logout") {
authManager.logout()
}
}
}
.onAppear { loadBooks() }
}
func loadBooks() {
guard let serverURL = authManager.serverURL,
let token = authManager.token else { return }
// Erst Libraries laden, dann Bücher
let url = URL(string: "\(serverURL)/api/libraries")!
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, _, error in
guard let data = data else { return }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let libraries = json["libraries"] as? [[String: Any]],
let firstLib = libraries.first,
let libId = firstLib["id"] as? String {
loadBooksFromLibrary(libId: libId, serverURL: serverURL, token: token)
}
}.resume()
}
func loadBooksFromLibrary(libId: String, serverURL: String, token: String) {
let url = URL(string: "\(serverURL)/api/libraries/\(libId)/items")!
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, _, _ in
guard let data = data else { return }
if let json = try? JSON

View File

@@ -0,0 +1,46 @@
import Foundation
// MARK: - Auth
struct LoginResponse: Codable {
let user: UserResponse
}
struct UserResponse: Codable {
let token: String
let id: String
}
// MARK: - Library
struct Library: Codable, Identifiable {
let id: String
let name: String
}
// MARK: - AudiobookItem
struct AudiobookItem: Identifiable {
let id: String
let title: String
let author: String
let coverURL: String?
let duration: Double
let mediaFiles: [MediaFile]
struct MediaFile {
let ino: String
let name: String
let path: String
}
}
// MARK: - Progress
struct LibraryProgress: Codable {
let currentTime: Double
let duration: Double?
let isFinished: Bool?
enum CodingKeys: String, CodingKey {
case currentTime
case duration
case isFinished
}
}

View File

@@ -0,0 +1,215 @@
import SwiftUI
import AVFoundation
struct PlayerView: View {
let book: AudiobookItem
@EnvironmentObject var authManager: AuthManager
@StateObject private var vm = PlayerViewModel()
var body: some View {
VStack(spacing: 24) {
// Cover
if let coverURL = book.coverURL, let url = URL(string: coverURL) {
AsyncImage(url: url) { image in
image.resizable().aspectRatio(contentMode: .fit)
} placeholder: {
Rectangle().fill(Color.gray.opacity(0.3))
.overlay(Image(systemName: "book.fill").font(.largeTitle).foregroundColor(.gray))
}
.frame(maxWidth: 280, maxHeight: 280)
.cornerRadius(12)
.shadow(radius: 8)
} else {
Rectangle().fill(Color.gray.opacity(0.3))
.frame(width: 280, height: 280)
.cornerRadius(12)
.overlay(Image(systemName: "book.fill").font(.largeTitle).foregroundColor(.gray))
}
// Titel & Autor
VStack(spacing: 4) {
Text(book.title)
.font(.title2).bold()
.multilineTextAlignment(.center)
Text(book.author)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.horizontal)
// Fortschrittsbalken
VStack(spacing: 4) {
Slider(value: $vm.currentTime, in: 0...max(vm.duration, 1)) { editing in
if !editing {
vm.seek(to: vm.currentTime)
}
}
.padding(.horizontal)
HStack {
Text(formatTime(vm.currentTime))
Spacer()
Text(formatTime(vm.duration))
}
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
}
// Steuerung
HStack(spacing: 40) {
Button(action: { vm.skip(-30) }) {
Image(systemName: "gobackward.30")
.font(.title)
}
Button(action: { vm.togglePlayPause() }) {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
}
Button(action: { vm.skip(30) }) {
Image(systemName: "goforward.30")
.font(.title)
}
}
.foregroundColor(.accentColor)
// Wiedergabegeschwindigkeit
HStack {
Text("Geschwindigkeit:")
.font(.caption)
Picker("", selection: $vm.playbackRate) {
Text("0.75x").tag(Float(0.75))
Text("1.0x").tag(Float(1.0))
Text("1.25x").tag(Float(1.25))
Text("1.5x").tag(Float(1.5))
Text("2.0x").tag(Float(2.0))
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
Spacer()
}
.padding(.top)
.navigationTitle("Player")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
vm.setup(book: book, authManager: authManager)
}
.onDisappear {
vm.saveProgress(authManager: authManager)
}
}
func formatTime(_ seconds: Double) -> String {
let s = Int(seconds)
return String(format: "%d:%02d:%02d", s/3600, (s%3600)/60, s%60)
}
}
// MARK: - PlayerViewModel
class PlayerViewModel: ObservableObject {
@Published var isPlaying = false
@Published var currentTime: Double = 0
@Published var duration: Double = 0
@Published var playbackRate: Float = 1.0 {
didSet { player?.rate = isPlaying ? playbackRate : 0 }
}
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
private var timeObserver: Any?
private var book: AudiobookItem?
private var authManager: AuthManager?
func setup(book: AudiobookItem, authManager: AuthManager) {
self.book = book
self.authManager = authManager
guard let serverURL = authManager.serverURL,
let token = authManager.token,
let firstFile = book.mediaFiles.first else { return }
let urlString = "\(serverURL)/api/items/\(book.id)/file/\(firstFile.ino)"
guard let url = URL(string: urlString) else { return }
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": ["Authorization": "Bearer \(token)"]])
playerItem = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: playerItem)
// Duration
Task {
if let dur = try? await asset.load(.duration) {
await MainActor.run {
self.duration = dur.seconds
}
}
}
// Fortschritt laden
Task {
if let progress = try? await ABSService.shared.fetchProgress(
serverURL: serverURL, token: token, itemId: book.id) {
await MainActor.run {
self.currentTime = progress.currentTime
self.player?.seek(to: CMTime(seconds: progress.currentTime, preferredTimescale: 1000))
}
}
}
// Time Observer
let interval = CMTime(seconds: 1, preferredTimescale: 1000)
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
self?.currentTime = time.seconds
}
}
func togglePlayPause() {
guard let player else { return }
if isPlaying {
player.pause()
} else {
player.rate = playbackRate
}
isPlaying.toggle()
}
func skip(_ seconds: Double) {
guard let player else { return }
let newTime = max(0, min(currentTime + seconds, duration))
player.seek(to: CMTime(seconds: newTime, preferredTimescale: 1000))
currentTime = newTime
}
func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 1000))
}
func saveProgress(authManager: AuthManager) {
guard let book,
let serverURL = authManager.serverURL,
let token = authManager.token else { return }
Task {
await ABSService.shared.updateProgress(
serverURL: serverURL,
token: token,
itemId: book.id,
currentTime: currentTime,
duration: duration
)
}
}
deinit {
if let timeObserver {
player?.removeTimeObserver(timeObserver)
}
player?.pause()
}
}

64
LibraryView.swift Normal file
View File

@@ -0,0 +1,64 @@
import SwiftUI
struct LibraryView: View {
@ObservedObject var authManager: AuthManager
@State private var books: [AudiobookItem] = []
@State private var isLoading = true
@State private var errorMessage = ""
var body: some View {
NavigationView {
Group {
if isLoading {
ProgressView("Lädt Bibliothek...")
} else if !errorMessage.isEmpty {
Text(errorMessage).foregroundColor(.red)
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) {
ForEach(books) { book in
BookCard(book: book, authManager: authManager)
}
}
.padding()
}
}
}
.navigationTitle("Meine Bibliothek")
.toolbar {
Button("Logout") {
authManager.logout()
}
}
}
.onAppear { loadBooks() }
}
func loadBooks() {
guard let serverURL = authManager.serverURL,
let token = authManager.token else { return }
// Erst Libraries laden, dann Bücher
let url = URL(string: "\(serverURL)/api/libraries")!
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, _, error in
guard let data = data else { return }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let libraries = json["libraries"] as? [[String: Any]],
let firstLib = libraries.first,
let libId = firstLib["id"] as? String {
loadBooksFromLibrary(libId: libId, serverURL: serverURL, token: token)
}
}.resume()
}
func loadBooksFromLibrary(libId: String, serverURL: String, token: String) {
let url = URL(string: "\(serverURL)/api/libraries/\(libId)/items")!
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, _, _ in
guard let data = data else { return }
if let json = try? JSON