All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
231 lines
8.6 KiB
Swift
231 lines
8.6 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
// MARK: - AudioDownloadService
|
|
// Manages offline TTS audio downloads with progress tracking.
|
|
// Uses a background URLSession so downloads survive app suspension.
|
|
// Keys use "::" separator (slugs contain hyphens).
|
|
|
|
@MainActor
|
|
final class AudioDownloadService: NSObject, ObservableObject {
|
|
static let shared = AudioDownloadService()
|
|
|
|
// MARK: - Published state
|
|
|
|
@Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice"
|
|
@Published var downloadedChapters: Set<String> = [] // key: "slug::chapter::voice"
|
|
|
|
// MARK: - Private
|
|
|
|
private var session: URLSession!
|
|
private var activeTasks: [String: URLSessionDownloadTask] = [:]
|
|
private let fileManager = FileManager.default
|
|
private let metadataKey = "v2.downloadedChapters"
|
|
|
|
// MARK: - Init
|
|
|
|
private override init() {
|
|
super.init()
|
|
let config = URLSessionConfiguration.background(
|
|
withIdentifier: "cc.kalekber.libnovel.v2.audio-downloads")
|
|
config.isDiscretionary = false
|
|
config.sessionSendsLaunchEvents = true
|
|
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
|
loadMetadata()
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool {
|
|
downloadedChapters.contains(makeKey(slug: slug, chapter: chapter, voice: voice))
|
|
}
|
|
|
|
func localURL(slug: String, chapter: Int, voice: String) -> URL? {
|
|
guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil }
|
|
return audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
|
}
|
|
|
|
func download(slug: String, chapter: Int, voice: String) async throws {
|
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
|
guard !downloadedChapters.contains(key), activeTasks[key] == nil else { return }
|
|
|
|
let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice)
|
|
guard let url = URL(string: urlString) else { throw URLError(.badURL) }
|
|
|
|
let task = session.downloadTask(with: url)
|
|
task.taskDescription = key
|
|
activeTasks[key] = task
|
|
|
|
downloads[key] = DownloadProgress(
|
|
slug: slug, chapter: chapter, voice: voice,
|
|
progress: 0, totalBytes: 0, downloadedBytes: 0, status: .downloading)
|
|
task.resume()
|
|
}
|
|
|
|
func cancelDownload(slug: String, chapter: Int, voice: String) {
|
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
|
activeTasks[key]?.cancel()
|
|
activeTasks.removeValue(forKey: key)
|
|
downloads.removeValue(forKey: key)
|
|
}
|
|
|
|
func deleteDownload(slug: String, chapter: Int, voice: String) throws {
|
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
|
let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
|
if fileManager.fileExists(atPath: fileURL.path) {
|
|
try fileManager.removeItem(at: fileURL)
|
|
}
|
|
downloadedChapters.remove(key)
|
|
downloads.removeValue(forKey: key)
|
|
saveMetadata()
|
|
}
|
|
|
|
func deleteAllDownloads() throws {
|
|
if let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
let audioDir = docs.appendingPathComponent("audio")
|
|
if fileManager.fileExists(atPath: audioDir.path) {
|
|
try fileManager.removeItem(at: audioDir)
|
|
}
|
|
}
|
|
downloadedChapters.removeAll()
|
|
downloads.removeAll()
|
|
activeTasks.values.forEach { $0.cancel() }
|
|
activeTasks.removeAll()
|
|
saveMetadata()
|
|
}
|
|
|
|
func totalStorageUsed() -> Int64 {
|
|
guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return 0 }
|
|
let audioDir = docs.appendingPathComponent("audio")
|
|
guard let enumerator = fileManager.enumerator(at: audioDir,
|
|
includingPropertiesForKeys: [.fileSizeKey]) else { return 0 }
|
|
var total: Int64 = 0
|
|
for case let url as URL in enumerator {
|
|
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
|
total += Int64(size)
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
func offlineBookSlugs() -> [String] {
|
|
Array(Set(downloadedChapters.compactMap { key -> String? in
|
|
let parts = key.split(separator: "::")
|
|
return parts.count == 3 ? String(parts[0]) : nil
|
|
})).sorted()
|
|
}
|
|
|
|
func downloadedChapterCount(for slug: String) -> Int {
|
|
downloadedChapters.filter { $0.hasPrefix("\(slug)::") }.count
|
|
}
|
|
|
|
// MARK: - Key / path helpers
|
|
|
|
func makeKey(slug: String, chapter: Int, voice: String) -> String {
|
|
"\(slug)::\(chapter)::\(voice)"
|
|
}
|
|
|
|
nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL {
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
return docs
|
|
.appendingPathComponent("audio")
|
|
.appendingPathComponent(slug)
|
|
.appendingPathComponent("\(chapter)-\(voice).mp3")
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func loadMetadata() {
|
|
if let data = UserDefaults.standard.data(forKey: metadataKey),
|
|
let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
|
downloadedChapters = decoded
|
|
}
|
|
}
|
|
|
|
private func saveMetadata() {
|
|
if let encoded = try? JSONEncoder().encode(downloadedChapters) {
|
|
UserDefaults.standard.set(encoded, forKey: metadataKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - URLSessionDownloadDelegate
|
|
|
|
extension AudioDownloadService: URLSessionDownloadDelegate {
|
|
|
|
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
didFinishDownloadingTo location: URL) {
|
|
guard let key = downloadTask.taskDescription else { return }
|
|
let parts = key.split(separator: "::")
|
|
guard parts.count == 3, let chapter = Int(parts[1]) else { return }
|
|
let slug = String(parts[0])
|
|
let voice = String(parts[2])
|
|
let dest = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
|
|
|
do {
|
|
let dir = dest.deletingLastPathComponent()
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
if FileManager.default.fileExists(atPath: dest.path) {
|
|
try FileManager.default.removeItem(at: dest)
|
|
}
|
|
try FileManager.default.moveItem(at: location, to: dest)
|
|
Task { @MainActor in
|
|
self.downloadedChapters.insert(key)
|
|
self.downloads.removeValue(forKey: key)
|
|
self.activeTasks.removeValue(forKey: key)
|
|
self.saveMetadata()
|
|
}
|
|
} catch {
|
|
Task { @MainActor in
|
|
self.downloads[key]?.status = .failed(error.localizedDescription)
|
|
self.activeTasks.removeValue(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
didWriteData _: Int64, totalBytesWritten: Int64,
|
|
totalBytesExpectedToWrite: Int64) {
|
|
guard let key = downloadTask.taskDescription else { return }
|
|
let progress = totalBytesExpectedToWrite > 0
|
|
? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
|
|
Task { @MainActor in
|
|
if var p = self.downloads[key] {
|
|
p.downloadedBytes = totalBytesWritten
|
|
p.totalBytes = totalBytesExpectedToWrite
|
|
p.progress = progress
|
|
self.downloads[key] = p
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask,
|
|
didCompleteWithError error: Error?) {
|
|
guard let key = task.taskDescription, let error else { return }
|
|
let nsErr = error as NSError
|
|
guard nsErr.code != NSURLErrorCancelled else { return }
|
|
Task { @MainActor in
|
|
self.downloads[key]?.status = .failed(error.localizedDescription)
|
|
self.activeTasks.removeValue(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Supporting types
|
|
|
|
struct DownloadProgress: Equatable {
|
|
let slug: String
|
|
let chapter: Int
|
|
let voice: String
|
|
var progress: Double
|
|
var totalBytes: Int64
|
|
var downloadedBytes: Int64
|
|
var status: DownloadStatus
|
|
}
|
|
|
|
enum DownloadStatus: Equatable {
|
|
case downloading
|
|
case completed
|
|
case failed(String)
|
|
}
|