damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }