Grundcode AudiobookshelfClient
This commit is contained in:
98
Audiobookshelf swift/ABSService.swift
Normal file
98
Audiobookshelf swift/ABSService.swift
Normal 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
|
||||
}
|
||||
0
Audiobookshelf swift/AuthManager.swift
Normal file
0
Audiobookshelf swift/AuthManager.swift
Normal file
72
Audiobookshelf swift/LibraryView.swift
Normal file
72
Audiobookshelf swift/LibraryView.swift
Normal 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
|
||||
46
Audiobookshelf swift/LoginResponse.swift
Normal file
46
Audiobookshelf swift/LoginResponse.swift
Normal 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
|
||||
}
|
||||
}
|
||||
215
Audiobookshelf swift/PlayerView.swift
Normal file
215
Audiobookshelf swift/PlayerView.swift
Normal 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
64
LibraryView.swift
Normal 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
|
||||
Reference in New Issue
Block a user