Files
ABS-Client/ABS Client/Audiobookshelf swift/Services/ABSClient.swift
Scarriffle 9497c6e315 Bidirectional progress sync, reliable background history, MB-accurate download ring
- Bidirectional progress sync: server's `lastUpdate` now parsed correctly; pull
  timer (60s) + scenePhase hook reconcile against local state. Server-newer
  while paused/playing stashes a `pendingServerProgress` and surfaces a prompt
  on next Play; server-older triggers an immediate push.
- History: lockscreen/Control-Center skip & scrub now route through AppState
  via `onRemoteSkip`/`onRemoteSeek` callbacks (previously bypassed history).
  `AppState.skip(by:)` itself now records the pre-skip position.
- Chapter detection moved to the AVPlayer periodic time observer — fires
  reliably while the app is backgrounded or the device is locked, where the
  5s runloop Timer can be throttled.
- Always fetch item detail when online (even for downloaded items) so
  `item.chapters` is populated and history entries get chapter titles.
- DownloadManager: per-track byte-fraction progress, so single-track 1+GB
  audiobooks' ring grows smoothly instead of staying at 0% until done.
- PlayerBar: extracted ScrubberView into its own struct so per-second time
  updates no longer re-render the parent (fixes iOS history-popup flicker).
- App icon: re-embedded sRGB profile in marketing icon; bumped version 2.0
  to 2.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:44:38 +02:00

277 lines
12 KiB
Swift

import Foundation
enum ABSClientError: LocalizedError {
case noAuth
case invalidURL
case httpStatus(Int)
case decoding(Error)
var errorDescription: String? {
switch self {
case .noAuth: return "Nicht angemeldet."
case .invalidURL: return "Ungültige URL."
case .httpStatus(let code): return "HTTP-Status \(code)."
case .decoding(let err): return "Antwort konnte nicht gelesen werden: \(err.localizedDescription)"
}
}
}
// Accepts any server certificate so that private servers with self-signed or
// custom-CA certificates work without needing the CA installed on-device.
// Acceptable because the user explicitly configures the server URL themselves.
private final class AnyServerTrustDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.performDefaultHandling, nil)
}
}
}
@MainActor
final class ABSClient {
private let auth: AuthStore
private let sessionDelegate = AnyServerTrustDelegate()
private(set) var session: URLSession = URLSession.shared
init(auth: AuthStore) {
self.auth = auth
let config = URLSessionConfiguration.default
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.waitsForConnectivity = false
self.session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil)
}
private func makeRequest(path: String, method: String = "GET", body: Data? = nil) throws -> URLRequest {
guard !auth.token.isEmpty, !auth.serverURL.isEmpty else { throw ABSClientError.noAuth }
guard let url = URL(string: auth.serverURL + path) else { throw ABSClientError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(auth.token)", forHTTPHeaderField: "Authorization")
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = body
}
return req
}
private func perform<T: Decodable>(_ req: URLRequest, as: T.Type) async throws -> T {
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw ABSClientError.httpStatus(0) }
guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw ABSClientError.decoding(error)
}
}
func fetchLibraries() async throws -> [Library] {
let req = try makeRequest(path: "/api/libraries")
let dto = try await perform(req, as: LibrariesResponseDTO.self)
return dto.libraries.map { Library(id: $0.id, name: $0.name, mediaType: $0.mediaType) }
}
func fetchItems(libraryId: String) async throws -> [LibraryItem] {
let req = try makeRequest(path: "/api/libraries/\(libraryId)/items?limit=500&sort=media.metadata.title")
let dto = try await perform(req, as: LibraryItemsResponseDTO.self)
return dto.results.map { Self.toLibraryItem(from: $0) }
}
private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
let meta = raw.media?.metadata
let mediaType = raw.mediaType ?? "book"
let files: [AudioFile] = (raw.media?.audioFiles ?? []).enumerated().map { idx, f in
AudioFile(
ino: f.ino,
filename: f.metadata?.filename ?? "track-\(idx).mp3",
ext: (f.metadata?.ext ?? "mp3").trimmingCharacters(in: CharacterSet(charactersIn: ".")),
durationSeconds: f.duration ?? 0,
index: f.index ?? idx
)
}.sorted { $0.index < $1.index }
let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
var item = LibraryItem(
id: raw.id,
title: meta?.title ?? "Unbekannt",
author: authorString,
durationSeconds: raw.media?.duration ?? 0,
audioFiles: files
)
item.mediaType = mediaType
item.description = meta?.description
item.chapters = (raw.media?.chapters ?? []).compactMap { c in
guard let id = c.id, let start = c.start, let end = c.end, let title = c.title else { return nil }
return Chapter(id: id, start: start, end: end, title: title)
}
return item
}
func fetchEpisodes(podcastItemId: String) async throws -> (LibraryItem, [PodcastEpisode]) {
let req = try makeRequest(path: "/api/items/\(podcastItemId)?expanded=1")
let raw = try await perform(req, as: LibraryItemDTO.self)
let item = Self.toLibraryItem(from: raw)
let episodes: [PodcastEpisode] = (raw.media?.episodes ?? []).compactMap { ep in
guard let af = ep.audioFile else { return nil }
let ext = (af.metadata?.ext ?? "mp3").trimmingCharacters(in: CharacterSet(charactersIn: "."))
let audioFile = AudioFile(
ino: af.ino,
filename: af.metadata?.filename ?? "episode.\(ext)",
ext: ext,
durationSeconds: af.duration ?? ep.duration ?? 0,
index: 0
)
return PodcastEpisode(
id: ep.id,
title: ep.title ?? "Folge",
pubDate: ep.pubDate,
publishedAtMillis: ep.publishedAt,
season: ep.season,
episode: ep.episode,
durationSeconds: ep.duration ?? af.duration ?? 0,
audioFile: audioFile
)
}.sorted { lhs, rhs in
(lhs.publishedAtMillis ?? 0) > (rhs.publishedAtMillis ?? 0)
}
return (item, episodes)
}
func fetchItemDetail(itemId: String) async throws -> LibraryItem {
let req = try makeRequest(path: "/api/items/\(itemId)?expanded=1")
let raw = try await perform(req, as: LibraryItemDTO.self)
return Self.toLibraryItem(from: raw)
}
private func progressPath(itemId: String, episodeId: String?) -> String {
if let episodeId { return "/api/me/progress/\(itemId)/\(episodeId)" }
return "/api/me/progress/\(itemId)"
}
func fetchProgress(itemId: String, episodeId: String? = nil) async throws -> PlaybackProgress? {
let req = try makeRequest(path: progressPath(itemId: itemId, episodeId: episodeId))
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { return nil }
if http.statusCode == 404 { return nil }
guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
let dto = try JSONDecoder().decode(ProgressResponseDTO.self, from: data)
return PlaybackProgress(
itemId: itemId,
episodeId: episodeId,
currentTime: dto.currentTime ?? 0,
duration: dto.duration ?? 0,
isFinished: dto.isFinished ?? false,
updatedAt: Self.parseLastUpdate(dto.lastUpdate)
)
}
private static func parseLastUpdate(_ ms: Double?) -> Date {
guard let ms, ms > 0 else { return Date() }
return Date(timeIntervalSince1970: ms / 1000)
}
func saveProgress(_ progress: PlaybackProgress) async throws {
let body: [String: Any] = [
"currentTime": progress.currentTime,
"duration": progress.duration,
"isFinished": progress.isFinished,
]
let data = try JSONSerialization.data(withJSONObject: body)
let req = try makeRequest(
path: progressPath(itemId: progress.itemId, episodeId: progress.episodeId),
method: "PATCH",
body: data
)
let (_, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
}
}
func fetchAllProgress() async throws -> [PlaybackProgress] {
let req = try makeRequest(path: "/api/me")
let dto = try await perform(req, as: MeResponseDTO.self)
return (dto.mediaProgress ?? []).compactMap { p in
guard let itemId = p.libraryItemId else { return nil }
return PlaybackProgress(
itemId: itemId,
episodeId: p.episodeId,
currentTime: p.currentTime ?? 0,
duration: p.duration ?? 0,
isFinished: p.isFinished ?? false,
updatedAt: Self.parseLastUpdate(p.lastUpdate)
)
}
}
func validateToken() async -> Bool {
guard let req = try? makeRequest(path: "/api/me") else { return false }
do {
let (_, response) = try await session.data(for: req)
return ((response as? HTTPURLResponse)?.statusCode ?? 0) < 400
} catch {
return false
}
}
func coverURL(itemId: String) -> URL? {
guard !auth.serverURL.isEmpty else { return nil }
var comps = URLComponents(string: auth.serverURL + "/api/items/\(itemId)/cover")
comps?.queryItems = [URLQueryItem(name: "token", value: auth.token)]
return comps?.url
}
func audioFileURL(itemId: String, ino: String) -> URL? {
var comps = URLComponents(string: auth.serverURL + "/api/items/\(itemId)/file/\(ino)")
comps?.queryItems = [URLQueryItem(name: "token", value: auth.token)]
return comps?.url
}
var bearerHeader: [String: String] { ["Authorization": "Bearer \(auth.token)"] }
// MARK: - Bookmarks
func fetchBookmarks(itemId: String, episodeId: String? = nil) async throws -> [ServerBookmark] {
let req = try makeRequest(path: progressPath(itemId: itemId, episodeId: episodeId))
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { return [] }
if http.statusCode == 404 { return [] }
guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
let dto = try JSONDecoder().decode(ProgressResponseDTO.self, from: data)
return (dto.bookmarks ?? []).compactMap { b in
guard let title = b.title, let time = b.time else { return nil }
return ServerBookmark(title: title, time: time, createdAt: b.createdAt)
}
}
func createBookmark(itemId: String, time: Double, title: String) async throws {
let body = try JSONSerialization.data(withJSONObject: ["title": title, "time": time])
let req = try makeRequest(path: "/api/me/item/\(itemId)/bookmark", method: "POST", body: body)
let (_, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
}
}
func deleteBookmark(itemId: String, time: Double) async throws {
let timeInt = Int(time)
let req = try makeRequest(path: "/api/me/item/\(itemId)/bookmark/\(timeInt)", method: "DELETE")
let (_, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
}
}
}
struct ServerBookmark {
let title: String
let time: Double
let createdAt: Double?
}