Files
ABS-Client/ABS Client/Audiobookshelf swift/Services/ABSClient.swift
Scarriffle a17a61ad9a Home screen, library-wide search, splash with orbital particles, macOS download fix
- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library
- Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library
- Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail
- Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished
- Add AirPlay picker in PlayerBar
- Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup
- Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s
- Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id)
- Bump CFBundleVersion to 8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 08:54:43 +02:00

355 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: Self.decodeHTMLEntities($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) }
}
/// Audiobookshelf serves podcast titles straight from the RSS feed, which
/// often contains HTML entities (`&uuml;`, `&euro;`, `&#8364;` etc.).
/// SwiftUI renders those literally, so we decode them here once at the
/// API boundary every title/author/chapter/library name goes through
/// this before reaching the UI.
static func decodeHTMLEntities(_ s: String) -> String {
guard s.contains("&") else { return s }
var result = s
let named: [String: String] = [
"&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
"&apos;": "'", "&nbsp;": " ",
"&euro;": "", "&pound;": "£", "&yen;": "¥",
"&copy;": "©", "&reg;": "®", "&trade;": "",
"&hellip;": "", "&mdash;": "", "&ndash;": "",
"&laquo;": "«", "&raquo;": "»",
"&bdquo;": "", "&ldquo;": "\u{201C}", "&rdquo;": "\u{201D}",
"&lsquo;": "\u{2018}", "&rsquo;": "\u{2019}",
"&auml;": "ä", "&Auml;": "Ä",
"&ouml;": "ö", "&Ouml;": "Ö",
"&uuml;": "ü", "&Uuml;": "Ü",
"&szlig;": "ß",
"&eacute;": "é", "&Eacute;": "É",
"&egrave;": "è", "&Egrave;": "È",
"&agrave;": "à", "&Agrave;": "À",
"&acirc;": "â", "&Acirc;": "Â",
"&ecirc;": "ê", "&Ecirc;": "Ê",
"&icirc;": "î", "&Icirc;": "Î",
"&ocirc;": "ô", "&Ocirc;": "Ô",
"&ucirc;": "û", "&Ucirc;": "Û",
"&ntilde;": "ñ", "&Ntilde;": "Ñ",
"&ccedil;": "ç", "&Ccedil;": "Ç",
]
for (entity, char) in named {
result = result.replacingOccurrences(of: entity, with: char)
}
// Numeric refs: &#1234; (decimal) or &#xABCD; (hex)
if result.contains("&#"),
let regex = try? NSRegularExpression(pattern: "&#(x?)([0-9A-Fa-f]+);") {
let nsRange = NSRange(result.startIndex..., in: result)
// Reverse so prior replacements don't shift later ranges.
let matches = regex.matches(in: result, range: nsRange).reversed()
for match in matches {
guard let full = Range(match.range, in: result),
let hexFlag = Range(match.range(at: 1), in: result),
let num = Range(match.range(at: 2), in: result) else { continue }
let isHex = !result[hexFlag].isEmpty
let numStr = String(result[num])
let code = isHex ? UInt32(numStr, radix: 16) : UInt32(numStr)
if let code, let scalar = Unicode.Scalar(code) {
result.replaceSubrange(full, with: String(scalar))
}
}
}
return result
}
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: decodeHTMLEntities(meta?.title ?? "Unbekannt"),
author: decodeHTMLEntities(authorString),
durationSeconds: raw.media?.duration ?? 0,
audioFiles: files
)
item.mediaType = mediaType
item.description = meta?.description.map { decodeHTMLEntities($0) }
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: decodeHTMLEntities(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: Self.decodeHTMLEntities(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),
id: dto.id
)
}
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)
}
}
/// Removes a media-progress entry on the server. Audiobookshelf's
/// `DELETE /api/me/progress/:id` matches by the progress entry's UUID,
/// NOT by libraryItemId (which is what PATCH uses) this is the source
/// of the long-running "delete didn't stick" bug. 404 is treated as
/// success (entry already gone).
func deleteProgress(progressId: String) async throws {
let req = try makeRequest(
path: "/api/me/progress/\(progressId)",
method: "DELETE"
)
let (_, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw ABSClientError.httpStatus(0)
}
if http.statusCode == 404 { return }
guard (200..<300).contains(http.statusCode) else {
throw ABSClientError.httpStatus(http.statusCode)
}
}
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),
id: p.id
)
}
}
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?
}