fixed some bugs

This commit is contained in:
2026-09-10 08:28:07 +08:00
parent 3908947ac2
commit 39896a5c24
5 changed files with 286 additions and 18 deletions

View File

@@ -0,0 +1,188 @@
import CryptoKit
import Foundation
/// iOS 端的持久化整文件视频缓存。
///
/// Android 端使用 ExoPlayer 的 CacheDataSource 边播边缓存;AVFoundation 没有公开
/// 等价的持久字节缓存 API。这里在后台把远程 MP4 下载成完整本地文件,下次装载时
/// 直接交给 AVPlayer,并在本次播放完成下载后热切换到本地文件,避免回跳句子时
/// 再次依赖网络。
@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?, Never>] = [:]
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 -> URL? {
if let cached = cachedFileIfAvailable(for: remoteURL) {
return cached
}
guard isCacheable(remoteURL), let destination = destinationFile(for: remoteURL) else {
return nil
}
if let existing = downloadsByURL[remoteURL] {
return await existing.value
}
let task = Task {
await Self.download(from: remoteURL, to: destination)
}
downloadsByURL[remoteURL] = task
let result = await task.value
if downloadsByURL[remoteURL]?.isCancelled != true {
downloadsByURL[remoteURL] = nil
}
if result != 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 -> URL? {
var request = URLRequest(url: remoteURL)
request.timeoutInterval = AppConfig.connectTimeout
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
request.cachePolicy = .reloadIgnoringLocalCacheData
do {
let (tempFile, response) = try await URLSession.shared.download(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
FileManager.default.removeItemIfPossible(at: tempFile)
logFailure(remoteURL, message: "HTTP \(status)")
return nil
}
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 is CancellationError {
return nil
} catch {
logFailure(remoteURL, message: error.localizedDescription)
return nil
}
}
private static func logFailure(_ url: URL, message: String) {
#if DEBUG
print("[StreamingVideoCache] 预缓存失败: \(url), \(message)")
#endif
}
}
private extension FileManager {
@discardableResult
func removeItemIfPossible(at url: URL) -> Bool {
do {
try removeItem(at: url)
return true
} catch {
return false
}
}
}