214 lines
7.7 KiB
Swift
214 lines
7.7 KiB
Swift
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<String> = ["mp4", "m4v", "mov"]
|
||
private static let fileExtension = "mp4"
|
||
|
||
private let directory: URL
|
||
private let fileManager = FileManager.default
|
||
private var downloadsByURL: [URL: Task<URL, Error>] = [:]
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|