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(_ 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 (`ü`, `€`, `€` 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] = [ "&": "&", "<": "<", ">": ">", """: "\"", "'": "'", " ": " ", "€": "€", "£": "£", "¥": "¥", "©": "©", "®": "®", "™": "™", "…": "…", "—": "—", "–": "–", "«": "«", "»": "»", "„": "„", "“": "\u{201C}", "”": "\u{201D}", "‘": "\u{2018}", "’": "\u{2019}", "ä": "ä", "Ä": "Ä", "ö": "ö", "Ö": "Ö", "ü": "ü", "Ü": "Ü", "ß": "ß", "é": "é", "É": "É", "è": "è", "È": "È", "à": "à", "À": "À", "â": "â", "Â": "Â", "ê": "ê", "Ê": "Ê", "î": "î", "Î": "Î", "ô": "ô", "Ô": "Ô", "û": "û", "Û": "Û", "ñ": "ñ", "Ñ": "Ñ", "ç": "ç", "Ç": "Ç", ] for (entity, char) in named { result = result.replacingOccurrences(of: entity, with: char) } // Numeric refs: Ӓ (decimal) or ꯍ (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? }