JohnCoates/Aerial

View on GitHub
Aerial/Source/Models/Cache/VideoDownload.swift

Summary

Maintainability
C
7 hrs
Test Coverage
//
//  VideoDownload.swift
//  Aerial
//
//  Created by John Coates on 10/31/15.
//  Copyright © 2015 John Coates. All rights reserved.
//

import Foundation

protocol VideoDownloadDelegate: NSObjectProtocol {
    func videoDownload(_ videoDownload: VideoDownload,
                       finished success: Bool, errorMessage: String?)
    // bytes received for bytes/second count
    func videoDownload(_ videoDownload: VideoDownload,
                       receivedBytes: Int, progress: Float)
}

final class VideoDownloadStream {
    var connection: NSURLConnection
    var response: URLResponse?
    var contentInformationRequest: Bool = false
    var downloadOffset = 0

    init(connection: NSURLConnection) {
        self.connection = connection
    }
    deinit {
        connection.cancel()
    }
}

final class VideoDownload: NSObject, NSURLConnectionDataDelegate {
    var streams: [VideoDownloadStream] = []
    weak var delegate: VideoDownloadDelegate!

    let queue = DispatchQueue.main

    let video: AerialVideo

    var data: NSMutableData?
    var downloadedData: Int = 0
    var contentLength: Int = 0

    init(video: AerialVideo, delegate: VideoDownloadDelegate) {
        self.video = video
        self.delegate = delegate
    }

    deinit {
        //print("deinit VideoDownload")
    }

    func startDownload() {
        // first start content information download
        startDownloadForContentInformation()
    }

    // download a couple bytes to get the content length
    func startDownloadForContentInformation() {
        startDownloadForChunk(nil)
    }

    func cancel() {
        for stream in streams {
            stream.connection.cancel()
        }
        infoLog("Video download cancelled")
        delegate.videoDownload(self, finished: false, errorMessage: nil)
    }

    func startDownloadForChunk(_ chunk: NSRange?) {
        let request = NSMutableURLRequest(url: video.url as URL)
        request.cachePolicy = NSURLRequest.CachePolicy.reloadIgnoringCacheData

        if let requestedRange = chunk {
            // set Range: bytes=startOffset-endOffset
            let requestRangeField = "bytes=\(requestedRange.location)-\(requestedRange.location+requestedRange.length)"
            request.setValue(requestRangeField, forHTTPHeaderField: "Range")
            debugLog("Starting download for range \(requestRangeField)")
        }

        guard let connection = NSURLConnection(request: request as URLRequest,
                                               delegate: self, startImmediately: false) else {
            errorLog("Error creating connection with request: \(request)")
            return
        }

        let stream = VideoDownloadStream(connection: connection)

        if chunk == nil {
            debugLog("Starting download for content information")
            stream.contentInformationRequest = true
        }

        connection.start()

        streams.append(stream)

    }

    func streamForConnection(_ connection: NSURLConnection) -> VideoDownloadStream? {
        return streams.first(where: { $0.connection == connection })
    }

    func createStreamsBasedOnContentLength(_ contentLength: Int) {
        self.contentLength = contentLength
        // remove content length request stream
        streams.removeFirst()

        data = NSMutableData(length: contentLength)

        // start 4 streams for maximum throughput
        let streamCount = 1 // TODO
        let pace = 0.2; // pace stream creation a little bit
        let streamPiece = Int(floor(Double(contentLength) / Double(streamCount)))
        debugLog("Starting \(streamCount) streams with \(streamPiece) each, for content length of \(contentLength)")
        var offset = 0

        var delayTime: Double = 0

//        let queue = DispatchQueue.main
        for idx in 0 ..< streamCount {
            let isLastStream: Bool = idx == (streamCount - 1)
            var range = NSRange(location: offset, length: streamPiece)

            if isLastStream {
                let bytesLeft = contentLength - offset
                range = NSRange(location: offset, length: bytesLeft)
                debugLog("last stream range: \(range)")
            }

            let delay = DispatchTime.now() + Double(Int64(delayTime * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
            queue.asyncAfter(deadline: delay) {
                self.startDownloadForChunk(range)
            }

            // increase delay
            delayTime += pace

            // increase offset
            offset += range.length
        }
    }

    func receiveDataForStream(_ stream: VideoDownloadStream, receivedData: Data) {
        guard let videoData = self.data else {
            errorLog("Aerial error: video data missing!")
            return
        }

        let replaceRange = NSRange(location: stream.downloadOffset,
                                   length: receivedData.count)
        videoData.replaceBytes(in: replaceRange, withBytes: (receivedData as NSData).bytes)
        stream.downloadOffset += receivedData.count
    }

    func finishedDownload() {
        var tentativeCachePath: String?

        if video.source.isCachable {
            tentativeCachePath = VideoCache.cachePath(forVideo: video)
        } else {
            tentativeCachePath = VideoCache.sourcePathFor(video)
        }

        guard let videoCachePath = tentativeCachePath else {
            errorLog("Couldn't save video because couldn't get cache path\n")
            failedDownload("Couldn't get cache path")
            return
        }

        if self.data == nil {
            errorLog("video data missing!\n")
            return
        }

        var success: Bool = true
        var errorMessage: String?
        do {
            try self.data!.write(toFile: videoCachePath, options: .atomicWrite)

            self.data = nil
        } catch let error {
            errorLog("Couldn't write cache file: \(error)")
            errorMessage = "Couldn't write to cache file!"
            success = false
        }

        // notify delegate
        delegate.videoDownload(self, finished: success, errorMessage: errorMessage)
    }

    func failedDownload(_ errorMessage: String) {

        delegate.videoDownload(self, finished: false, errorMessage: errorMessage)
    }

    // MARK: - NSURLConnection Delegate

    func connection(_ connection: NSURLConnection, didReceive response: URLResponse) {
        guard let stream = streamForConnection(connection) else {
            errorLog("No matching stream for connection: \(connection) with response: \(response)")
            return
        }

        stream.response = response as? HTTPURLResponse

        if stream.contentInformationRequest == true {
            connection.cancel()

            queue.async(execute: { () -> Void in
                let contentLength = Int(response.expectedContentLength)
                self.createStreamsBasedOnContentLength(contentLength)
            })

            return
        } else {
            // get real offset of receiving data

            queue.async(execute: { () -> Void in
                guard let offset = self.startOffsetFromResponse(response) else {
                    errorLog("Couldn't get start offset from response: \(response)")
                    return
                }

                stream.downloadOffset = offset
            })
        }
    }

    func connection(_ connection: NSURLConnection, didReceive data: Data) {
        guard let delegate = self.delegate else {
            return
        }

        queue.async { () -> Void in
            self.downloadedData += data.count
            let progress: Float = Float(self.downloadedData) / Float(self.contentLength)
            delegate.videoDownload(self, receivedBytes: data.count, progress: progress)

            guard let stream = self.streamForConnection(connection) else {
                errorLog("No matching stream for connection: \(connection)")
                return
            }

            self.receiveDataForStream(stream, receivedData: data)
        }
    }

    func connectionDidFinishLoading(_ connection: NSURLConnection) {
        queue.async { () -> Void in
            debugLog("connectionDidFinishLoading")

            guard let stream = self.streamForConnection(connection) else {
                errorLog("No matching stream for connection: \(connection)")
                return
            }

            guard let index = self.streams.firstIndex(where: { $0.connection == stream.connection }) else {
                errorLog("Couldn't find index of stream for finished connection!")
                return
            }

            self.streams.remove(at: index)

            if self.streams.isEmpty {
                debugLog("Finished downloading!")
                self.finishedDownload()
            }
        }
    }

    func connection(_ connection: NSURLConnection, didFailWithError error: Error) {
        errorLog("Couldn't download video: \(error.localizedDescription)")
        queue.async { () -> Void in
            self.failedDownload("Connection fail: \(error.localizedDescription)")
        }
    }

    func connection(_ connection: NSURLConnection, didReceive challenge: URLAuthenticationChallenge) {
        errorLog("Didn't expect authentication challenge while downloading videos!")
        queue.async { () -> Void in
            self.failedDownload("Connection fail: Received authentication request!")
        }
    }

    // MARK: - Range
    func startOffsetFromResponse(_ response: URLResponse) -> Int? {
        // get range response
        var regex: NSRegularExpression!
        do {
            // Check to see if the server returned a valid byte-range
            regex = try NSRegularExpression(pattern: "bytes (\\d+)-\\d+/\\d+",
                                            options: NSRegularExpression.Options.caseInsensitive)
        } catch let error as NSError {
            errorLog("Error formatting regex: \(error)")
            return nil
        }

        let httpResponse = response as! HTTPURLResponse

        guard let contentRange = httpResponse.allHeaderFields["Content-Range"] as? NSString else {
            errorLog("Weird, no byte response: \(response)")
            return nil
        }

        guard let match = regex.firstMatch(in: contentRange as String,
                                           options: NSRegularExpression.MatchingOptions.anchored,
                                           range: NSRange(location: 0, length: contentRange.length)) else {
            errorLog("Weird, couldn't make a regex match for byte offset: \(contentRange)")
            return nil
        }
        let offsetMatchRange = match.range(at: 1)
        let offsetString = contentRange.substring(with: offsetMatchRange) as NSString

        let offset = offsetString.longLongValue

        return Int(offset)
    }
}