- 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>
355 lines
16 KiB
Swift
355 lines
16 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: 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?
|
||
}
|