Merge iOS and Mac app into one
This commit is contained in:
228
ABS Client/Audiobookshelf swift/Services/ABSClient.swift
Normal file
228
ABS Client/Audiobookshelf swift/Services/ABSClient.swift
Normal file
@@ -0,0 +1,228 @@
|
||||
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
|
||||
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: Date()
|
||||
)
|
||||
}
|
||||
|
||||
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: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)"] }
|
||||
}
|
||||
Reference in New Issue
Block a user