Files
libnovel/ios/LibNovelV2/Services/AudioDownloadService.swift
Admin 7413313100
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
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

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)
}