Aerial/Source/Models/Cache/VideoCache.swift
//
// VideoCache.swift
// Aerial
//
// Created by John Coates on 10/29/15.
// Copyright © 2015 John Coates. All rights reserved.
//
import Foundation
import AVFoundation
import ScreenSaver
final class VideoCache {
var videoData: Data
var mutableVideoData: NSMutableData?
var loading: Bool
var loadedRanges: [NSRange] = []
let URL: URL
static var computedCacheDirectory: String?
static var computedAppSupportDirectory: String?
// MARK: - Application Support directory
static var appSupportDirectory: String? {
// TODO : temporary for the migration
return Cache.supportPath
//
// // We only process this once if successful
// if computedAppSupportDirectory != nil {
// return computedAppSupportDirectory
// }
//
// var foundDirectory: String?
//
// let appSupportPaths = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory,
// .userDomainMask,
// true)
//
// if appSupportPaths.isEmpty {
// errorLog("Couldn't find appSupport paths!")
// return nil
// }
// let appSupportDirectory = appSupportPaths[0] as NSString
// if aerialFolderExists(at: appSupportDirectory) {
// debugLog("app support exists")
// foundDirectory = appSupportDirectory.appendingPathComponent("Aerial")
// } else {
// debugLog("creating app support directory")
// // We create in user appSupport which may be containairized
// // so ~/Library/Application Support/ on pre 10.15
// // or ~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver
// // /Data/Library/Application Support/
// foundDirectory = appSupportDirectory.appendingPathComponent("Aerial")
//
// let fileManager = FileManager.default
// if fileManager.fileExists(atPath: foundDirectory!) == false {
// do {
// try fileManager.createDirectory(atPath: foundDirectory!,
// withIntermediateDirectories: false, attributes: nil)
// } catch let error {
// errorLog("Couldn't create appSupport directory in User directory: \(error)")
// errorLog("FATAL : There's nothing more we can do at this point")
// return nil
// }
// }
// }
//
// // Cache the computed value
// computedAppSupportDirectory = foundDirectory
// return computedAppSupportDirectory
}
// MARK: - User Video cache directory
static var cacheDirectory: String? {
// TODO : Until refactor is done
return Cache.path
// // We only process this once if successful
// if computedCacheDirectory != nil {
// return computedCacheDirectory
// }
//
// var cacheDirectory: String?
// let preferences = Preferences.sharedInstance
//
// if let customCacheDirectory = preferences.customCacheDirectory {
// // We may have overriden the cache directory, but it may no longer exist !
// if FileManager.default.fileExists(atPath: customCacheDirectory as String) {
// debugLog("Using exiting customCacheDirectory : \(customCacheDirectory)")
// cacheDirectory = customCacheDirectory
// } /*else {
// // If it doesn't we need to reset that preference
// preferences.customCacheDirectory = nil
// }*/
// }
//
// if cacheDirectory == nil {
// let userCachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory,
// .userDomainMask,
// true)
// let localCachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory,
// .localDomainMask,
// true)
//
// if !localCachePaths.isEmpty {
// let localCacheDirectory = localCachePaths[0] as NSString
// if aerialFolderExists(at: localCacheDirectory) {
// debugLog("Using existing local cache /Library/Caches/Aerial")
// cacheDirectory = localCacheDirectory.appendingPathComponent("Aerial")
// }
// }
//
// if !userCachePaths.isEmpty && cacheDirectory == nil {
// let userCacheDirectory = userCachePaths[0] as NSString
//
// if aerialFolderExists(at: userCacheDirectory) {
// debugLog("Using existing user cache ~/Library/Caches/Aerial")
// cacheDirectory = userCacheDirectory.appendingPathComponent("Aerial")
// } else {
// debugLog("No local or user cache exists, using ~/Library/Application Support/Aerial")
// cacheDirectory = appSupportDirectory
// }
// }
// }
//
// // Cache the computed value
// computedCacheDirectory = cacheDirectory
//
// debugLog("cache to be used : \(String(describing: cacheDirectory))")
// return cacheDirectory
}
// MARK: - Helpers
static func aerialFolderExists(at: NSString) -> Bool {
let aerialFolder = at.appendingPathComponent("Aerial")
if FileManager.default.fileExists(atPath: aerialFolder as String) {
return true
} else {
return false
}
}
// Is a video cached (in either appSupport or cache)
static func isAvailableOffline(video: AerialVideo) -> Bool {
let fileManager = FileManager.default
if video.url.absoluteString.starts(with: "file") {
return fileManager.fileExists(atPath: video.url.path)
} else {
if video.source.isCachable {
guard let videoCachePath = cachePath(forVideo: video) else {
errorLog("Couldn't get video cache path!")
return false
}
return fileManager.fileExists(atPath: videoCachePath)
} else {
let path = sourcePathFor(video)
return fileManager.fileExists(atPath: path)
}
}
}
static func moveToTrash(video: AerialVideo) {
let videoCachePath = VideoList.instance.localPathFor(video: video)
guard videoCachePath != "" else {
errorLog("Couldn't get video cache path to trash!")
return
}
let vurl = Foundation.URL(fileURLWithPath: videoCachePath as String)
debugLog("trashing \(vurl))")
do {
try FileManager.default.trashItem(at: vurl, resultingItemURL: nil)
} catch let error {
errorLog("Could not move \(video.url) to trash \(error)")
}
}
static func cachePath(forVideo video: AerialVideo) -> String? {
if video.url.absoluteString.starts(with: "file") {
return video.url.path
}
let vurl = video.url
let filename = vurl.lastPathComponent
return cachePath(forFilename: filename)
}
static func cachePath(forFilename filename: String) -> String? {
guard let cacheDirectory = VideoCache.cacheDirectory, let appSupportDirectory = VideoCache.appSupportDirectory else {
return nil
}
// Let's compute both
let appSupportPath = appSupportDirectory as NSString
let appSupportVideoPath = appSupportPath.appendingPathComponent(filename)
let cacheDirectoryPath = cacheDirectory as NSString
let cacheVideoPath = cacheDirectoryPath.appendingPathComponent(filename)
// If the file exists in either dir, returns that
if FileManager.default.fileExists(atPath: appSupportVideoPath as String) {
return appSupportVideoPath
} else if FileManager.default.fileExists(atPath: cacheVideoPath as String) {
return cacheVideoPath
} else {
// File doesn't have to exist, this is also used to compute the save location
// So now with Catalina, considering containerization we need to use appSupport
// Pre catalina we return cache folder instead (no change for users)
return cacheVideoPath
/*
if #available(OSX 10.15, *) {
return appSupportVideoPath
} else {
return cacheVideoPath
}
*/
}
}
static func sourcePathFor(_ video: AerialVideo) -> String {
if video.url.isFileURL {
return video.url.path
} else {
return Cache.supportPath.appending("/" + video.source.name + "/" + video.url.lastPathComponent)
}
}
static func sourcePathFor(_ filename: String, video: AerialVideo) -> String {
return Cache.supportPath.appending("/" + video.source.name + "/" + filename)
}
init(URL: Foundation.URL) {
debugLog("initvideocache")
videoData = Data()
loading = true
self.URL = URL
loadCachedVideoIfPossible()
}
// MARK: - Data Adding
func receivedContentLength(_ contentLength: Int) {
if loading == false {
return
}
if mutableVideoData != nil {
return
}
mutableVideoData = NSMutableData(length: contentLength)
videoData = mutableVideoData! as Data
}
func receivedData(_ data: Data, atRange range: NSRange) {
guard let mutableVideoData = mutableVideoData else {
errorLog("Received data without having mutable video data")
return
}
mutableVideoData.replaceBytes(in: range, withBytes: (data as NSData).bytes)
loadedRanges.append(range)
consolidateLoadedRanges()
// debugLog("loaded ranges: \(loadedRanges)")
if loadedRanges.count == 1 {
let range = loadedRanges[0]
// debugLog("checking if range \(range) matches length \(mutableVideoData.length)")
if range.location == 0 && range.length == mutableVideoData.length {
// done loading, save
saveCachedVideo()
}
}
}
// MARK: - Save / Load Cache
var videoCachePath: String? {
let filename = URL.lastPathComponent
if let video = VideoList.instance.videoForFilename(filename) {
if !video.source.isCachable {
return VideoCache.sourcePathFor(filename, video: video)
}
}
return VideoCache.cachePath(forFilename: filename)
}
func saveCachedVideo() {
let fileManager = FileManager.default
guard let videoCachePath = videoCachePath else {
errorLog("Couldn't save cache file")
return
}
guard fileManager.fileExists(atPath: videoCachePath) == false else {
errorLog("Cache file \(videoCachePath) already exists.")
return
}
loading = false
if mutableVideoData == nil {
errorLog("Missing video data for save.")
return
}
do {
try mutableVideoData!.write(toFile: videoCachePath, options: .atomicWrite)
mutableVideoData = nil
videoData.removeAll()
} catch let error {
errorLog("Couldn't write cache file: \(error)")
}
}
func loadCachedVideoIfPossible() {
let fileManager = FileManager.default
guard let videoCachePath = self.videoCachePath else {
errorLog("Couldn't load cache file.")
return
}
if fileManager.fileExists(atPath: videoCachePath) == false {
return
}
guard let videoData = try? Data(contentsOf: Foundation.URL(fileURLWithPath: videoCachePath)) else {
errorLog("NSData failed to load cache file \(videoCachePath)")
return
}
self.videoData = videoData
loading = false
debugLog("cached video file with length: \(self.videoData.count)")
}
// MARK: - Fulfilling cache
func fulfillLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
guard let dataRequest = loadingRequest.dataRequest else {
errorLog("Missing data request for \(loadingRequest)")
return false
}
let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = Int(dataRequest.requestedLength)
let data = videoData.subdata(in: requestedOffset..<requestedOffset + requestedLength)
DispatchQueue.main.async { () -> Void in
self.fillInContentInformation(loadingRequest)
dataRequest.respond(with: data)
loadingRequest.finishLoading()
}
return true
}
func fillInContentInformation(_ loadingRequest: AVAssetResourceLoadingRequest) {
guard let contentInformationRequest = loadingRequest.contentInformationRequest else {
return
}
let contentType: String = kUTTypeQuickTimeMovie as String
contentInformationRequest.isByteRangeAccessSupported = true
contentInformationRequest.contentType = contentType
contentInformationRequest.contentLength = Int64(videoData.count)
}
// MARK: - Cache Checking
// Whether the video cache can fulfill this request
func canFulfillLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
if !loading {
return true
}
guard let dataRequest = loadingRequest.dataRequest else {
errorLog("Missing data request for \(loadingRequest)")
return false
}
let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = Int(dataRequest.requestedLength)
let requestedEnd = requestedOffset + requestedLength
for range in loadedRanges {
let rangeStart = range.location
let rangeEnd = range.location + range.length
if requestedOffset >= rangeStart && requestedEnd <= rangeEnd {
return true
}
}
return false
}
// MARK: - Consolidating
func consolidateLoadedRanges() {
var consolidatedRanges: [NSRange] = []
let sortedRanges = loadedRanges.sorted { $0.location < $1.location }
var previousRange: NSRange?
var lastIndex: Int?
for range in sortedRanges {
if let lastRange: NSRange = previousRange {
let lastRangeEndOffset = lastRange.location + lastRange.length
// check if range can be consumed by lastRange
// or if they're at each other's edges if it can be merged
if lastRangeEndOffset >= range.location {
let endOffset = range.location + range.length
// check if this range's end offset is larger than lastRange's
if endOffset > lastRangeEndOffset {
previousRange!.length = endOffset - lastRange.location
// replace lastRange in array with new value
consolidatedRanges.remove(at: lastIndex!)
consolidatedRanges.append(previousRange!)
continue
} else {
// skip adding this to the array, previous range is already bigger
// debugLog("skipping add of \(range), previous: \(previousRange)")
continue
}
}
}
lastIndex = consolidatedRanges.count
previousRange = range
consolidatedRanges.append(range)
}
loadedRanges = consolidatedRanges
}
}