VideoCache.swift (5048B)
1 // 2 // VideoCache.swift 3 // damus 4 // 5 // Created by Daniel D'Aquino on 2024-04-01. 6 // 7 import Foundation 8 import CryptoKit 9 10 // Default expiry time of only 1 day to prevent using too much storage 11 fileprivate let DEFAULT_EXPIRY_TIME: TimeInterval = 60*60*24 12 // Default cache directory is in the system-provided caches directory, so that the operating system can delete files when it needs storage space 13 // (https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html) 14 fileprivate let DEFAULT_CACHE_DIRECTORY_PATH: URL? = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("video_cache") 15 16 struct VideoCache { 17 private let cache_url: URL 18 private let expiry_time: TimeInterval 19 static let standard: VideoCache? = try? VideoCache() 20 21 init?(cache_url: URL? = nil, expiry_time: TimeInterval = DEFAULT_EXPIRY_TIME) throws { 22 guard let cache_url_to_apply = cache_url ?? DEFAULT_CACHE_DIRECTORY_PATH else { return nil } 23 self.cache_url = cache_url_to_apply 24 self.expiry_time = expiry_time 25 26 // Create the cache directory if it doesn't exist 27 do { 28 try FileManager.default.createDirectory(at: self.cache_url, withIntermediateDirectories: true, attributes: nil) 29 } catch { 30 Log.error("Could not create cache directory: %s", for: .storage, error.localizedDescription) 31 throw error 32 } 33 } 34 35 /// Checks for a cached video and returns its URL if available, otherwise downloads and caches the video. 36 func maybe_cached_url_for(video_url: URL) throws -> URL { 37 let cached_url = url_to_cached_url(url: video_url) 38 39 if FileManager.default.fileExists(atPath: cached_url.path) { 40 // Check if the cached video has expired 41 let file_attributes = try FileManager.default.attributesOfItem(atPath: cached_url.path) 42 if let modification_date = file_attributes[.modificationDate] as? Date, Date().timeIntervalSince(modification_date) <= expiry_time { 43 // Video is not expired 44 return cached_url 45 } else { 46 Task { 47 // Video is expired, delete and re-download on the background 48 try FileManager.default.removeItem(at: cached_url) 49 return try await download_and_cache_video(from: video_url) 50 } 51 return video_url 52 } 53 } else { 54 Task { 55 // Video is not cached, download and cache on the background 56 return try await download_and_cache_video(from: video_url) 57 } 58 return video_url 59 } 60 } 61 62 /// Downloads video content using URLSession and caches it to disk. 63 private func download_and_cache_video(from url: URL) async throws -> URL { 64 let (data, response) = try await URLSession.shared.data(from: url) 65 66 guard let http_response = response as? HTTPURLResponse, 67 200..<300 ~= http_response.statusCode else { 68 throw URLError(.badServerResponse) 69 } 70 71 let destination_url = url_to_cached_url(url: url) 72 73 try data.write(to: destination_url) 74 return destination_url 75 } 76 77 func url_to_cached_url(url: URL) -> URL { 78 let hashed_url = hash_url(url) 79 let file_extension = url.pathExtension 80 return self.cache_url.appendingPathComponent(hashed_url + "." + file_extension) 81 } 82 83 /// Deletes all cached videos older than the expiry time. 84 func periodic_purge(completion: ((Error?) -> Void)? = nil) { 85 DispatchQueue.global(qos: .background).async { 86 Log.info("Starting periodic video cache purge", for: .storage) 87 let file_manager = FileManager.default 88 do { 89 let cached_files = try file_manager.contentsOfDirectory(at: self.cache_url, includingPropertiesForKeys: [.contentModificationDateKey], options: .skipsHiddenFiles) 90 91 for file in cached_files { 92 let attributes = try file.resourceValues(forKeys: [.contentModificationDateKey]) 93 if let modification_date = attributes.contentModificationDate, Date().timeIntervalSince(modification_date) > self.expiry_time { 94 try file_manager.removeItem(at: file) 95 } 96 } 97 DispatchQueue.main.async { 98 completion?(nil) 99 } 100 } catch { 101 DispatchQueue.main.async { 102 completion?(error) 103 } 104 } 105 } 106 } 107 108 /// Hashes the URL using SHA-256 109 private func hash_url(_ url: URL) -> String { 110 let data = Data(url.absoluteString.utf8) 111 let hashed_data = SHA256.hash(data: data) 112 return hashed_data.compactMap { String(format: "%02x", $0) }.joined() 113 } 114 }