import CryptoKit import Foundation /// iOS 端的持久化整文件视频缓存。 /// /// AVFoundation 没有公开可持久化的媒体字节缓存 API。这里先把远程视频完整 /// 下载成原子本地文件;只有文件 /// 完整落盘后才返回给播放器,避免用部分响应或播放列表创建可播放的 AVPlayerItem。 @MainActor final class StreamingVideoCache { static let shared = StreamingVideoCache() static let maxCacheBytes: Int64 = 512 * 1024 * 1024 private static let supportedExtensions: Set = ["mp4", "m4v", "mov"] private static let fileExtension = "mp4" private let directory: URL private let fileManager = FileManager.default private var downloadsByURL: [URL: Task] = [:] private init() { let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] directory = caches.appendingPathComponent("oral-trainer-streaming-videos", isDirectory: true) try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } func cachedFileIfAvailable(for remoteURL: URL) -> URL? { guard isCacheable(remoteURL), let file = destinationFile(for: remoteURL) else { return nil } guard fileManager.fileExists(atPath: file.path), isUsableMediaFile(file) else { fileManager.removeItemIfPossible(at: file) return nil } // AVPlayer 读取后刷新 LRU 时间,与 Android LeastRecentlyUsedCacheEvictor 的 // 语义保持接近。 try? fileManager.setAttributes( [.modificationDate: Date()], ofItemAtPath: file.path ) return file } /// 重复调用同一 URL 时复用同一个后台下载;只有下载完成后才返回本地文件。 func prefetch(_ remoteURL: URL) async throws -> URL { if let cached = cachedFileIfAvailable(for: remoteURL) { return cached } guard let destination = destinationFile(for: remoteURL) else { throw StreamingVideoCacheError.unsupportedURL } guard isCacheable(remoteURL) else { throw StreamingVideoCacheError.unsupportedURL } if let existing = downloadsByURL[remoteURL] { return try await existing.value } let task = Task { try await Self.download(from: remoteURL, to: destination) } downloadsByURL[remoteURL] = task let result: URL do { result = try await task.value } catch { if !task.isCancelled { downloadsByURL[remoteURL] = nil } throw error } if downloadsByURL[remoteURL]?.isCancelled != true { downloadsByURL[remoteURL] = nil } evictIfNeeded(keeping: remoteURL) return result } func clear() { for task in downloadsByURL.values { task.cancel() } downloadsByURL.removeAll() try? fileManager.removeItem(at: directory) try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } private func isCacheable(_ url: URL) -> Bool { guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return false } let ext = url.pathExtension.lowercased() // HLS/DASH 播放列表不能通过下载单个清单完成离线缓存;当前云端课程均为 MP4。 return Self.supportedExtensions.contains(ext) } private func destinationFile(for remoteURL: URL) -> URL? { let rawData = Data(remoteURL.absoluteString.utf8) let digest = SHA256.hash(data: rawData) let key = digest.map { String(format: "%02x", $0) }.joined() return directory.appendingPathComponent(key).appendingPathExtension(Self.fileExtension) } private func isUsableMediaFile(_ url: URL) -> Bool { let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) guard values?.isRegularFile == true, let fileSize = values?.fileSize else { return false } let size = Int64(fileSize) return size > 0 } private func evictIfNeeded(keeping remoteURL: URL) { guard let keeping = destinationFile(for: remoteURL) else { return } let contents = (try? fileManager.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], options: [.skipsHiddenFiles] )) ?? [] var entries: [(url: URL, size: Int64, date: Date)] = contents.compactMap { url in let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) guard let fileSize = values?.fileSize, fileSize > 0 else { fileManager.removeItemIfPossible(at: url) return nil } let size = Int64(fileSize) return (url, size, values?.contentModificationDate ?? .distantPast) } var totalBytes = entries.reduce(0) { $0 + $1.size } entries.sort { $0.date < $1.date } for entry in entries where totalBytes > Self.maxCacheBytes && entry.url != keeping { guard fileManager.removeItemIfPossible(at: entry.url) else { continue } totalBytes -= entry.size } } private static func download(from remoteURL: URL, to destination: URL) async throws -> URL { var request = URLRequest(url: remoteURL) request.timeoutInterval = AppConfig.connectTimeout request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent") request.cachePolicy = .reloadIgnoringLocalCacheData let (tempFile, response) = try await URLSession.shared.download(for: request) do { guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { let status = (response as? HTTPURLResponse)?.statusCode ?? -1 throw StreamingVideoCacheError.downloadFailed(message: "HTTP \(status)") } let fileManager = FileManager.default try? fileManager.removeItem(at: destination) try fileManager.createDirectory( at: destination.deletingLastPathComponent(), withIntermediateDirectories: true ) try fileManager.moveItem(at: tempFile, to: destination) return destination } catch { FileManager.default.removeItemIfPossible(at: tempFile) if let cacheError = error as? StreamingVideoCacheError { logFailure(remoteURL, message: cacheError.localizedDescription) throw cacheError } logFailure(remoteURL, message: error.localizedDescription) throw StreamingVideoCacheError.downloadFailed( message: error.localizedDescription ) } } private static func logFailure(_ url: URL, message: String) { #if DEBUG print("[StreamingVideoCache] 预缓存失败: \(url), \(message)") #endif } } enum StreamingVideoCacheError: LocalizedError { case unsupportedURL case downloadFailed(message: String) var errorDescription: String? { switch self { case .unsupportedURL: return "仅支持 MP4/M4V/MOV 整文件缓存" case .downloadFailed(let message): return message } } } private extension FileManager { @discardableResult func removeItemIfPossible(at url: URL) -> Bool { do { try removeItem(at: url) return true } catch { return false } } }