Files
mediaplayer/ios/OralTrainer/Services/StreamingVideoCache.swift
2026-09-13 12:58:45 +08:00

213 lines
7.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CryptoKit
import Foundation
/// iOS 端的可选整文件视频缓存。
///
/// 播放默认走远程 URL 按需加载(AVPlayer HTTP Range)。若本地已有完整缓存文件,
/// 播放器会优先使用该文件,避免重复拉流。
@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
}
}
}