Restructure project folders

This commit is contained in:
Scarriffle
2026-05-17 08:45:37 +02:00
parent 652cfc4cf4
commit 069f8bac2d
80 changed files with 6102 additions and 547 deletions

View File

@@ -0,0 +1,209 @@
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)"
}
}
}
@MainActor
final class ABSClient {
private let auth: AuthStore
private let session: URLSession
init(auth: AuthStore) {
self.auth = auth
let config = URLSessionConfiguration.default
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.waitsForConnectivity = false
self.session = URLSession(configuration: config)
}
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)"] }
}

View File

@@ -0,0 +1,224 @@
import Foundation
import Observation
@Observable
@MainActor
final class AppState {
let auth: AuthStore
let client: ABSClient
let network: NetworkMonitor
let downloads: DownloadManager
let sync: ProgressSyncManager
let player: PlayerEngine
var currentItem: LibraryItem?
var isPreparingPlayback: Bool = false
/// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress).
/// Used to show progress bars on covers in the library views.
var progressCache: [String: PlaybackProgress] = [:]
private var syncTimer: Timer?
private var lastReportedSecond: Double = -10
init() {
let auth = AuthStore()
let client = ABSClient(auth: auth)
self.auth = auth
self.client = client
self.network = NetworkMonitor()
self.downloads = DownloadManager(client: client)
self.sync = ProgressSyncManager(client: client)
self.player = PlayerEngine()
}
func bootstrap() async {
auth.restoreSession()
network.start { [weak self] online in
guard let self else { return }
if online {
Task { [weak self] in
await self?.sync.drain()
await self?.refreshProgressCache()
}
}
}
if auth.isLoggedIn {
let ok = await client.validateToken()
if !ok {
auth.logout()
} else {
await sync.drain()
await refreshProgressCache()
}
}
}
/// Pulls the entire progress map from the server (via /api/me).
func refreshProgressCache() async {
guard network.isOnline, auth.isLoggedIn else { return }
do {
let all = try await client.fetchAllProgress()
progressCache = Dictionary(all.map { ($0.syncKey, $0) }, uniquingKeysWith: { _, new in new })
} catch {
// non-fatal
}
}
/// Local update for the cache while we're actively playing.
func cacheProgress(itemId: String, episodeId: String?, currentTime: Double, duration: Double, isFinished: Bool) {
let p = PlaybackProgress(
itemId: itemId, episodeId: episodeId,
currentTime: currentTime, duration: duration,
isFinished: isFinished, updatedAt: Date()
)
progressCache[p.syncKey] = p
}
func progress(for item: LibraryItem) -> PlaybackProgress? {
progressCache[item.syncKey]
}
func progressFraction(itemId: String, episodeId: String? = nil) -> Double {
let key = episodeId.map { "\(itemId)|\($0)" } ?? itemId
guard let p = progressCache[key], p.duration > 0 else { return 0 }
if p.isFinished { return 1.0 }
return min(1, max(0, p.currentTime / p.duration))
}
func play(item: LibraryItem) async {
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
player.play()
return
}
stopPlayback(reportFinal: true)
isPreparingPlayback = true
defer { isPreparingPlayback = false }
var workItem = item
// Only fetch detail for books with empty audioFiles (podcast episodes
// arrive with their single audioFile already populated by the caller).
if !workItem.isPodcast && workItem.audioFiles.isEmpty && network.isOnline {
let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey)
if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) {
workItem = detail
}
}
var startAt: Double = 0
if network.isOnline {
if let p = try? await client.fetchProgress(itemId: item.id, episodeId: workItem.episodeId) {
// Replaying a finished item (or one with progress essentially at the end)
// should start from the beginning, not drop the user at the last few seconds.
let nearEnd = p.duration > 0 && p.currentTime >= p.duration - 10
if !p.isFinished && !nearEnd {
startAt = p.currentTime
}
}
}
currentItem = workItem
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
if player.errorMessage == nil {
player.play()
startSyncTimer()
}
}
/// Convenience for podcast episodes.
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
var synthetic = LibraryItem(
id: podcast.id,
title: episode.title,
author: podcast.title,
durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
audioFiles: [episode.audioFile]
)
synthetic.mediaType = "podcast"
synthetic.episodeId = episode.id
await play(item: synthetic)
}
func stopPlayback(reportFinal: Bool = true) {
if reportFinal { reportProgress(force: true) }
syncTimer?.invalidate()
syncTimer = nil
player.teardown()
currentItem = nil
lastReportedSecond = -10
}
func togglePlay() {
guard currentItem != nil else { return }
player.togglePlay()
if !player.isPlaying { reportProgress(force: true) }
}
func skip(by seconds: Double) {
guard currentItem != nil else { return }
player.skip(by: seconds)
reportProgress(force: true)
}
func seekAbsolute(_ target: Double) {
guard currentItem != nil else { return }
player.seekAbsolute(target)
reportProgress(force: true)
}
func setRate(_ newRate: Float) {
player.setRate(newRate)
}
private func startSyncTimer() {
syncTimer?.invalidate()
let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
Task { @MainActor [weak self] in
self?.reportProgress(force: false)
}
}
RunLoop.main.add(timer, forMode: .common)
syncTimer = timer
}
private func reportProgress(force: Bool) {
guard let item = currentItem else { return }
let t = player.absoluteCurrentTime
let d = player.totalDuration
guard d > 0 else { return }
if !force && abs(t - lastReportedSecond) < 3 { return }
lastReportedSecond = t
let finished = (d - t) < 30
cacheProgress(
itemId: item.id,
episodeId: item.episodeId,
currentTime: t,
duration: d,
isFinished: finished
)
Task {
await sync.report(
itemId: item.id,
episodeId: item.episodeId,
currentTime: t,
duration: d,
isFinished: finished,
isOnline: network.isOnline
)
}
}
}
extension LibraryItem {
/// Matches PlaybackProgress.syncKey for cache lookups.
var syncKey: String {
if let episodeId { return "\(id)|\(episodeId)" }
return id
}
/// The DownloadManager keys downloads by this composite identifier,
/// allowing the same podcast item to host multiple per-episode downloads.
var downloadKey: String { syncKey }
}

View File

@@ -0,0 +1,94 @@
import Foundation
import Observation
enum AuthError: LocalizedError {
case invalidURL
case badResponse(Int)
case noToken
case unknown(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "Ungültige Server-URL."
case .badResponse(let code): return "Server antwortete mit Status \(code)."
case .noToken: return "Login fehlgeschlagen: kein Token erhalten."
case .unknown(let msg): return msg
}
}
}
@Observable
@MainActor
final class AuthStore {
var isLoggedIn: Bool = false
var serverURL: String = ""
var username: String = ""
var token: String = ""
var errorMessage: String?
func restoreSession() {
guard let creds = KeychainStore.load() else { return }
self.serverURL = creds.serverURL
self.username = creds.username
self.token = creds.token
self.isLoggedIn = true
}
func login(serverURL rawURL: String, username: String, password: String, remember: Bool) async {
errorMessage = nil
let normalized = Self.normalizeURL(rawURL)
guard let url = URL(string: normalized + "/login") else {
errorMessage = AuthError.invalidURL.errorDescription
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["username": username, "password": password]
request.httpBody = try? JSONEncoder().encode(body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
errorMessage = "Keine HTTP-Antwort vom Server."
return
}
guard (200..<300).contains(http.statusCode) else {
errorMessage = AuthError.badResponse(http.statusCode).errorDescription
return
}
let decoded = try JSONDecoder().decode(LoginResponseDTO.self, from: data)
self.serverURL = normalized
self.username = decoded.user.username ?? username
self.token = decoded.user.token
self.isLoggedIn = true
if remember {
try? KeychainStore.save(StoredCredentials(
serverURL: normalized,
username: self.username,
token: self.token
))
} else {
KeychainStore.delete()
}
} catch {
errorMessage = "Login fehlgeschlagen: \(error.localizedDescription)"
}
}
func logout() {
KeychainStore.delete()
token = ""
isLoggedIn = false
}
static func normalizeURL(_ raw: String) -> String {
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
while s.hasSuffix("/") { s.removeLast() }
if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") {
s = "https://" + s
}
return s
}
}

View File

@@ -0,0 +1,259 @@
import Foundation
import Observation
struct DownloadedTrack: Codable, Hashable {
let ino: String
let filename: String
let localPath: String // relative to AppPaths.downloadsDirectory
let durationSeconds: Double
enum CodingKeys: String, CodingKey {
case ino, filename, localPath, durationSeconds
}
init(ino: String, filename: String, localPath: String, durationSeconds: Double) {
self.ino = ino
self.filename = filename
self.localPath = localPath
self.durationSeconds = durationSeconds
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
ino = try c.decode(String.self, forKey: .ino)
filename = try c.decode(String.self, forKey: .filename)
localPath = try c.decode(String.self, forKey: .localPath)
durationSeconds = try c.decodeIfPresent(Double.self, forKey: .durationSeconds) ?? 0
}
}
struct DownloadedItem: Codable, Hashable {
let itemId: String
var episodeId: String?
let title: String
let author: String
let durationSeconds: Double
let tracks: [DownloadedTrack]
enum CodingKeys: String, CodingKey {
case itemId, episodeId, title, author, durationSeconds, tracks
}
init(itemId: String, episodeId: String? = nil, title: String, author: String, durationSeconds: Double, tracks: [DownloadedTrack]) {
self.itemId = itemId
self.episodeId = episodeId
self.title = title
self.author = author
self.durationSeconds = durationSeconds
self.tracks = tracks
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
itemId = try c.decode(String.self, forKey: .itemId)
episodeId = try c.decodeIfPresent(String.self, forKey: .episodeId)
title = try c.decode(String.self, forKey: .title)
author = try c.decode(String.self, forKey: .author)
durationSeconds = try c.decode(Double.self, forKey: .durationSeconds)
tracks = try c.decode([DownloadedTrack].self, forKey: .tracks)
}
var downloadKey: String {
if let episodeId { return "\(itemId)|\(episodeId)" }
return itemId
}
}
@Observable
@MainActor
final class DownloadManager {
private let client: ABSClient
/// Keyed by downloadKey (itemId or "itemId|episodeId").
private(set) var states: [String: DownloadState] = [:]
private(set) var downloadedItems: [String: DownloadedItem] = [:]
private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
private var activeTasks: [String: Task<Void, Never>] = [:]
init(client: ABSClient) {
self.client = client
try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true)
loadIndex()
}
func state(for downloadKey: String) -> DownloadState {
states[downloadKey] ?? .notDownloaded
}
func isDownloaded(downloadKey: String) -> Bool {
if case .downloaded = state(for: downloadKey) { return true }
return false
}
func localTrackURLs(for downloadKey: String) -> [URL]? {
guard let item = downloadedItems[downloadKey] else { return nil }
return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) }
}
/// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile).
/// For a podcast episode pass the synthetic LibraryItem that the AppState builds
/// (item.id == podcastItemId, item.episodeId == episodeId, audioFiles == [episode.audioFile]).
func startDownload(item: LibraryItem) {
let key = item.downloadKey
guard activeTasks[key] == nil else { return }
states[key] = .downloading(progress: 0)
let task = Task { @MainActor [weak self] in
guard let self else { return }
var workItem = item
// Books may arrive with empty audioFiles (list endpoint omits them).
// Episodes always arrive populated, since AppState builds the synthetic item.
if !workItem.isPodcast && workItem.audioFiles.isEmpty {
do {
workItem = try await self.client.fetchItemDetail(itemId: item.id)
} catch {
self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
self.activeTasks[key] = nil
return
}
}
if workItem.audioFiles.isEmpty {
self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
self.activeTasks[key] = nil
return
}
await self.performDownload(workItem: workItem, downloadKey: key)
self.activeTasks[key] = nil
}
activeTasks[key] = task
}
func cancel(downloadKey: String) {
activeTasks[downloadKey]?.cancel()
activeTasks[downloadKey] = nil
states[downloadKey] = .notDownloaded
}
func delete(downloadKey: String) {
cancel(downloadKey: downloadKey)
if let item = downloadedItems[downloadKey] {
let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
try? FileManager.default.removeItem(at: dir)
// If this was an episode and the podcast's parent directory is now empty, clean it up too.
if item.episodeId != nil {
let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId)
if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty {
try? FileManager.default.removeItem(at: parent)
}
}
}
downloadedItems.removeValue(forKey: downloadKey)
states[downloadKey] = .notDownloaded
persistIndex()
}
private func directoryURL(itemId: String, episodeId: String?) -> URL {
var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true)
if let episodeId {
dir = dir.appendingPathComponent(episodeId, isDirectory: true)
}
return dir
}
private func relativePath(itemId: String, episodeId: String?, fileName: String) -> String {
if let episodeId { return "\(itemId)/\(episodeId)/\(fileName)" }
return "\(itemId)/\(fileName)"
}
private func performDownload(workItem: LibraryItem, downloadKey: String) async {
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
do {
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
} catch {
states[downloadKey] = .failed(message: error.localizedDescription)
return
}
var tracks: [DownloadedTrack] = []
let total = max(workItem.audioFiles.count, 1)
for (idx, file) in workItem.audioFiles.enumerated() {
if Task.isCancelled {
states[downloadKey] = .notDownloaded
return
}
guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
var request = URLRequest(url: url)
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
do {
let (tempURL, response) = try await URLSession.shared.download(for: request)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
states[downloadKey] = .failed(message: "HTTP \(http.statusCode) bei Datei \(file.filename)")
try? FileManager.default.removeItem(at: tempURL)
return
}
let ext = file.ext.isEmpty ? "mp3" : file.ext
let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"
let dest = itemDir.appendingPathComponent(destName)
try? FileManager.default.removeItem(at: dest)
try FileManager.default.moveItem(at: tempURL, to: dest)
tracks.append(DownloadedTrack(
ino: file.ino,
filename: file.filename,
localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName),
durationSeconds: file.durationSeconds
))
states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
} catch {
states[downloadKey] = .failed(message: error.localizedDescription)
return
}
}
let downloaded = DownloadedItem(
itemId: workItem.id,
episodeId: workItem.episodeId,
title: workItem.title,
author: workItem.author,
durationSeconds: workItem.durationSeconds,
tracks: tracks
)
downloadedItems[downloadKey] = downloaded
states[downloadKey] = .downloaded
persistIndex()
}
private func loadIndex() {
guard let data = try? Data(contentsOf: indexFile),
let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return }
// Re-key by current downloadKey (handles both legacy itemId-only keys and new composite keys).
var rekeyed: [String: DownloadedItem] = [:]
for (_, item) in decoded {
if item.tracks.isEmpty { continue }
rekeyed[item.downloadKey] = item
}
downloadedItems = rekeyed
for k in rekeyed.keys {
states[k] = .downloaded
}
// Clean up phantom folders.
for (oldKey, item) in decoded where item.tracks.isEmpty {
let dir = AppPaths.downloadsDirectory.appendingPathComponent(oldKey)
try? FileManager.default.removeItem(at: dir)
}
if rekeyed.count != decoded.count {
persistIndex()
}
}
private func persistIndex() {
do {
let data = try JSONEncoder().encode(downloadedItems)
try data.write(to: indexFile, options: .atomic)
} catch {
// non-fatal
}
}
}

View File

@@ -0,0 +1,59 @@
import Foundation
import Security
struct StoredCredentials: Codable {
let serverURL: String
let username: String
let token: String
}
enum KeychainError: Error {
case osStatus(OSStatus)
case encodingFailed
}
enum KeychainStore {
private static let service = "com.local.Audiobookshelf-swift.auth"
private static let account = "primary"
static func save(_ creds: StoredCredentials) throws {
let data = try JSONEncoder().encode(creds)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
var attributes = query
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.osStatus(status) }
}
static func load() -> StoredCredentials? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return try? JSONDecoder().decode(StoredCredentials.self, from: data)
}
static func delete() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
import Network
import Observation
@Observable
@MainActor
final class NetworkMonitor {
var isOnline: Bool = true
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
func start(onChange: @escaping @MainActor (Bool) -> Void) {
monitor.pathUpdateHandler = { [weak self] path in
let online = path.status == .satisfied
Task { @MainActor [weak self] in
guard let self else { return }
let previous = self.isOnline
self.isOnline = online
if previous != online {
onChange(online)
}
}
}
monitor.start(queue: queue)
}
func stop() {
monitor.cancel()
}
}

View File

@@ -0,0 +1,354 @@
import Foundation
import AVFoundation
import MediaPlayer
import Observation
#if canImport(UIKit)
import UIKit
private typealias PlayerArtworkImage = UIImage
#else
import AppKit
private typealias PlayerArtworkImage = NSImage
#endif
@Observable
@MainActor
final class PlayerEngine {
var isPlaying: Bool = false
var absoluteCurrentTime: Double = 0
var totalDuration: Double = 0
var rate: Float = 1.0
var isReady: Bool = false
var errorMessage: String?
private var player: AVQueuePlayer?
private var trackDurations: [Double] = []
private var trackPlayerItems: [AVPlayerItem] = []
private var currentTrackIndex: Int = 0
private var timeObserver: Any?
private var endObservers: [NSObjectProtocol] = []
private var isSeeking: Bool = false
var itemId: String?
// Now-playing metadata that travels with the current item.
private var currentTitle: String = ""
private var currentAuthor: String = ""
private var currentCoverURL: URL?
private var remoteCommandsConfigured: Bool = false
nonisolated init() {}
func load(item: LibraryItem, client: ABSClient, downloads: DownloadManager, startAt absoluteTime: Double) {
teardown()
self.itemId = item.id
self.errorMessage = nil
let useLocal = downloads.isDownloaded(downloadKey: item.downloadKey)
let urls: [URL]
if useLocal, let localURLs = downloads.localTrackURLs(for: item.downloadKey), !localURLs.isEmpty {
urls = localURLs
trackDurations = (0..<localURLs.count).map { idx in
idx < item.audioFiles.count ? item.audioFiles[idx].durationSeconds : 0
}
} else {
guard !item.audioFiles.isEmpty else {
errorMessage = "Dieses Hörbuch enthält keine abspielbaren Audiodateien."
return
}
urls = item.audioFiles.compactMap { client.audioFileURL(itemId: item.id, ino: $0.ino) }
trackDurations = item.audioFiles.map { $0.durationSeconds }
}
// When audio files carry no duration (e.g. some podcast episodes or
// freshly-scanned items), fall back to the item's reported total.
// Distribute equally across all tracks so that trackDurations.count
// always matches trackPlayerItems.count a mismatch breaks
// handleTrackEnd and refreshAbsoluteTime.
if trackDurations.allSatisfy({ $0 <= 0 }) && item.durationSeconds > 0 {
let count = max(1, trackDurations.count)
let perTrack = item.durationSeconds / Double(count)
trackDurations = Array(repeating: perTrack, count: count)
}
totalDuration = trackDurations.reduce(0, +)
trackPlayerItems = urls.map { AVPlayerItem(url: $0) }
let queue = AVQueuePlayer(items: trackPlayerItems)
queue.rate = rate
self.player = queue
let center = NotificationCenter.default
for (idx, playerItem) in trackPlayerItems.enumerated() {
let token = center.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleTrackEnd(finishedIndex: idx)
}
}
endObservers.append(token)
}
seekAbsolute(absoluteTime)
timeObserver = queue.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.refreshAbsoluteTime()
}
}
currentTitle = item.title
currentAuthor = item.author
currentCoverURL = client.coverURL(itemId: item.id)
configureRemoteCommandsIfNeeded()
updateNowPlayingInfo()
fetchAndAttachArtwork()
isReady = true
}
func play() {
player?.play()
player?.rate = rate
isPlaying = true
updateNowPlayingInfo()
}
func pause() {
player?.pause()
isPlaying = false
updateNowPlayingInfo()
}
func togglePlay() {
isPlaying ? pause() : play()
}
func setRate(_ newRate: Float) {
rate = newRate
if isPlaying { player?.rate = newRate }
updateNowPlayingInfo()
}
func skip(by seconds: Double) {
seekAbsolute(absoluteCurrentTime + seconds)
}
func seekAbsolute(_ target: Double) {
let clamped = max(0, min(target, max(totalDuration - 0.5, 0)))
var remaining = clamped
var trackIndex = 0
for (idx, dur) in trackDurations.enumerated() {
if remaining <= dur || idx == trackDurations.count - 1 {
trackIndex = idx
break
}
remaining -= dur
}
switchToTrack(index: trackIndex)
absoluteCurrentTime = clamped
guard let currentItem = player?.currentItem else {
// No item to seek (e.g. empty queue after failed insert) don't
// leave isSeeking stuck, which would freeze refreshAbsoluteTime.
return
}
isSeeking = true
let cmTime = CMTime(seconds: max(0, remaining), preferredTimescale: 600)
// Use a small tolerance so seeking succeeds on formats where exact
// keyframe alignment isn't guaranteed (e.g. VBR MP3 without a Xing
// header). Zero-tolerance seeks fail silently on such files, causing
// the slider to snap back because the player position never moved.
let tolerance = CMTime(seconds: 0.5, preferredTimescale: 600)
currentItem.seek(to: cmTime, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] _ in
Task { @MainActor [weak self] in
self?.isSeeking = false
self?.refreshAbsoluteTime()
self?.updateNowPlayingInfo()
}
}
}
private func switchToTrack(index: Int) {
guard index < trackPlayerItems.count, let player else { return }
if index == currentTrackIndex, player.currentItem === trackPlayerItems[index] { return }
let wasPlaying = isPlaying
player.removeAllItems()
for i in index..<trackPlayerItems.count {
let it = trackPlayerItems[i]
it.seek(to: .zero, completionHandler: nil)
if player.canInsert(it, after: nil) {
player.insert(it, after: nil)
}
}
currentTrackIndex = index
if wasPlaying { player.play(); player.rate = rate }
}
private func handleTrackEnd(finishedIndex: Int) {
if finishedIndex < trackDurations.count - 1 {
currentTrackIndex = finishedIndex + 1
} else {
isPlaying = false
updateNowPlayingInfo()
}
}
private func refreshAbsoluteTime() {
guard let player, let current = player.currentItem else { return }
if isSeeking { return }
if let idx = trackPlayerItems.firstIndex(where: { $0 === current }) {
currentTrackIndex = idx
}
let trackTime = current.currentTime().seconds
let prior = trackDurations.prefix(currentTrackIndex).reduce(0, +)
let absolute = prior + (trackTime.isFinite ? trackTime : 0)
// AVPlayer can report an item duration slightly longer than the metadata we have.
// Clamp the visible time so the scrubber/labels never exceed totalDuration.
let cap = totalDuration > 0 ? totalDuration : absolute
absoluteCurrentTime = max(0, min(absolute, cap))
let wasPlaying = isPlaying
isPlaying = player.timeControlStatus == .playing
if wasPlaying != isPlaying { updateNowPlayingInfo() }
}
func teardown() {
if let token = timeObserver { player?.removeTimeObserver(token) }
timeObserver = nil
for obs in endObservers { NotificationCenter.default.removeObserver(obs) }
endObservers.removeAll()
player?.pause()
player?.removeAllItems()
player = nil
trackPlayerItems.removeAll()
trackDurations.removeAll()
isPlaying = false
isReady = false
absoluteCurrentTime = 0
totalDuration = 0
currentTrackIndex = 0
itemId = nil
errorMessage = nil
isSeeking = false
currentTitle = ""
currentAuthor = ""
currentCoverURL = nil
clearNowPlayingInfo()
}
// MARK: - Now-playing / remote commands
private func configureRemoteCommandsIfNeeded() {
guard !remoteCommandsConfigured else { return }
remoteCommandsConfigured = true
let center = MPRemoteCommandCenter.shared()
center.playCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.play() }
return .success
}
center.pauseCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.pause() }
return .success
}
center.togglePlayPauseCommand.addTarget { [weak self] _ in
Task { @MainActor in self?.togglePlay() }
return .success
}
applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
center.skipForwardCommand.addTarget { [weak self] _ in
let s = Double(Self.currentSkipSeconds())
Task { @MainActor in self?.skip(by: s) }
return .success
}
center.skipBackwardCommand.addTarget { [weak self] _ in
let s = Double(Self.currentSkipSeconds())
Task { @MainActor in self?.skip(by: -s) }
return .success
}
// Keep the lock-screen icon in sync with the user's preference.
NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
}
}
center.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
let target = posEvent.positionTime
Task { @MainActor in self?.seekAbsolute(target) }
return .success
}
center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
center.changePlaybackRateCommand.addTarget { [weak self] event in
guard let rateEvent = event as? MPChangePlaybackRateCommandEvent else { return .commandFailed }
Task { @MainActor in self?.setRate(rateEvent.playbackRate) }
return .success
}
}
private func updateNowPlayingInfo() {
guard itemId != nil else {
clearNowPlayingInfo()
return
}
var info: [String: Any] = [
MPMediaItemPropertyTitle: currentTitle,
MPMediaItemPropertyArtist: currentAuthor,
MPMediaItemPropertyPlaybackDuration: totalDuration,
MPNowPlayingInfoPropertyElapsedPlaybackTime: absoluteCurrentTime,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? Double(rate) : 0.0,
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
]
// Preserve artwork across updates so we don't blank the lock-screen image.
if let existing = MPNowPlayingInfoCenter.default().nowPlayingInfo,
let art = existing[MPMediaItemPropertyArtwork] {
info[MPMediaItemPropertyArtwork] = art
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
private func fetchAndAttachArtwork() {
guard let url = currentCoverURL else { return }
Task.detached {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let img = PlayerArtworkImage(data: data) else { return }
let artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img }
await MainActor.run { [weak self] in
// Drop the result if the user has since switched items.
guard let self, self.currentCoverURL == url else { return }
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
info[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
} catch { }
}
}
private func clearNowPlayingInfo() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
private func applyRemoteSkipInterval(seconds: Int) {
let center = MPRemoteCommandCenter.shared()
center.skipForwardCommand.preferredIntervals = [NSNumber(value: seconds)]
center.skipBackwardCommand.preferredIntervals = [NSNumber(value: seconds)]
}
/// Reads the user-configured skip duration; defaults to 30s when unset.
static func currentSkipSeconds() -> Int {
let raw = UserDefaults.standard.integer(forKey: "skipDurationSeconds")
return raw > 0 ? raw : 30
}
}

View File

@@ -0,0 +1,94 @@
import Foundation
import Observation
@Observable
@MainActor
final class ProgressSyncManager {
private let client: ABSClient
private(set) var queuedCount: Int = 0
private(set) var lastSyncError: String?
/// Latest progress per itemId, persisted to disk.
private var queue: [String: PlaybackProgress] = [:]
private let queueFile: URL
init(client: ABSClient) {
self.client = client
let dir = AppPaths.supportDirectory
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.queueFile = dir.appendingPathComponent("progress-queue.json")
loadQueue()
}
func report(itemId: String, episodeId: String? = nil, currentTime: Double, duration: Double, isFinished: Bool, isOnline: Bool) async {
let progress = PlaybackProgress(
itemId: itemId,
episodeId: episodeId,
currentTime: currentTime,
duration: duration,
isFinished: isFinished,
updatedAt: Date()
)
let key = progress.syncKey
if isOnline {
do {
try await client.saveProgress(progress)
queue.removeValue(forKey: key)
persist()
lastSyncError = nil
return
} catch {
lastSyncError = error.localizedDescription
}
}
queue[key] = progress
persist()
}
func drain() async {
guard !queue.isEmpty else { return }
let snapshot = queue
for (id, progress) in snapshot {
do {
try await client.saveProgress(progress)
queue.removeValue(forKey: id)
} catch {
lastSyncError = error.localizedDescription
break
}
}
persist()
}
private func loadQueue() {
guard let data = try? Data(contentsOf: queueFile),
let decoded = try? JSONDecoder().decode([String: PlaybackProgress].self, from: data) else { return }
queue = decoded
queuedCount = decoded.count
}
private func persist() {
queuedCount = queue.count
do {
let data = try JSONEncoder().encode(queue)
try data.write(to: queueFile, options: .atomic)
} catch {
lastSyncError = "Queue konnte nicht gespeichert werden: \(error.localizedDescription)"
}
}
}
enum AppPaths {
static var supportDirectory: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
return base.appendingPathComponent("AudiobookshelfClient", isDirectory: true)
}
static var downloadsDirectory: URL {
supportDirectory.appendingPathComponent("downloads", isDirectory: true)
}
}