// // LNVoiceResourceManager.swift // Lanu // // Created by OneeChan on 2026/1/4. // import Foundation import AVFoundation private class LNVoiceLocalSourceInfo: Codable { @LNVisitedTimeWrapper var path: String init(path: String) { self.path = path } } class LNVoiceResourceManager { static let shared = LNVoiceResourceManager() private let lock = NSLock() private let maxFileCount = 100 private var voiceFileCache: [LNVoiceSource: [String: LNVoiceLocalSourceInfo]] = LNUserDefaults[.voiceCache, [:]] private var loadValueAssets: [String: AVURLAsset] = [:] private var voiceDurationMap: [String: Double] = [:] func voicePath(_ name: String, type: LNVoiceSource) -> URL { return URL.voiceCacheFolder .appendingPathComponent(type.folderName) .appendingPathComponent(name) } func voiceResourceFor(url: String, type: LNVoiceSource, cachePath: URL? = nil, completion: ((String) -> Void)?) { if let cachePath, FileManager.default.fileExists(atPath: cachePath.path) { completion?(cachePath.path) return } lock.lock() let cache = voiceFileCache[type]?[url] lock.unlock() if let cache { if FileManager.default.fileExists(atPath: cache.path) { saveCache() completion?(cache.path) return } else { lock.lock() voiceFileCache[type]?.removeValue(forKey: url) lock.unlock() saveCache() } } downloadVoice(url: url, type: type, customPath: cachePath, completion: completion) } func downloadVoice(url: String, type: LNVoiceSource, customPath: URL? = nil, completion: ((String) -> Void)? = nil) { let path = customPath ?? voicePath(url.md5, type: type) LNFileDownloader.shared.startDownload( from: url, destinationPath: path, completionHandler: { [weak self] result in guard let self else { return } guard case .success(let sourcePath) = result else { return } completion?(sourcePath.path) DispatchQueue.global().async { [weak self] in guard let self else { return } lock.lock() var cach = voiceFileCache[type] ?? [:] cach[url] = LNVoiceLocalSourceInfo(path: sourcePath.path) // 缓存文件数超过限制 if cach.count > maxFileCount { let keys = cach.sorted { $0.value.$path.visited < $1.value.$path.visited }.map { $0.key } // LRU 规则, 移除一半旧缓存 keys.prefix(maxFileCount / 2).forEach { if let path = cach[$0]?.path, FileManager.default.fileExists(atPath: path) { try? FileManager.default.removeItem(atPath: path) } cach.removeValue(forKey: $0) } } voiceFileCache[type] = cach lock.unlock() saveCache() } }) } func getRemoteAudioDuration(urlStr: String?, completion: @escaping (TimeInterval?, Error?) -> Void) { guard let urlStr else { completion(nil, nil) return } lock.lock() let cache = voiceDurationMap[urlStr] lock.unlock() if let cache { completion(cache, nil) return } guard let url = URL(string: urlStr) else { completion(nil, nil) return } let options: [String: Any] = [ AVURLAssetPreferPreciseDurationAndTimingKey: true ] let asset = AVURLAsset(url: url, options: options) loadValueAssets[urlStr] = asset asset.loadValuesAsynchronously(forKeys: ["duration"]) { [weak self] in guard let self else { return } var error: NSError? let status = asset.statusOfValue(forKey: "duration", error: &error) runOnMain { [weak self] in guard let self else { return } switch status { case .loaded: let duration = asset.duration.seconds lock.lock() voiceDurationMap[urlStr] = duration lock.unlock() completion(duration, nil) case .failed: completion(nil, error) case .cancelled: completion(nil, NSError(domain: "AudioDuration", code: -1, userInfo: [NSLocalizedDescriptionKey: "加载取消"])) default: completion(nil, NSError(domain: "AudioDuration", code: -2, userInfo: [NSLocalizedDescriptionKey: "加载失败"])) } loadValueAssets.removeValue(forKey: urlStr) } } } func cancelLoadingAsset(urlStr: String) { guard let asset = loadValueAssets.removeValue(forKey: urlStr) else { return } asset.cancelLoading() } } extension LNVoiceResourceManager { private func saveCache() { lock.lock() LNUserDefaults[.voiceCache] = voiceFileCache lock.unlock() } }