Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updates code to replace mp4 downloads and use hls streams instead #554

Merged
merged 6 commits into from
Apr 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Emitron/Emitron/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
persistenceStore: persistenceStore,
downloadService: downloadService
)
downloadService.startProcessing()
downloadService.startProcessing()
}

// MARK: UISceneSession Lifecycle
Expand Down
3 changes: 2 additions & 1 deletion Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,8 @@ private extension VideoPlaybackViewModel {
if let download = state.download,
download.state == .complete,
let localURL = download.localURL {
let item = AVPlayerItem(url: localURL)
let asset = AVURLAsset(url: localURL)
let item = AVPlayerItem(asset: asset)
self.addMetadata(from: state, to: item)
self.addClosedCaptions(for: item)
// Add it to the cache
Expand Down
111 changes: 81 additions & 30 deletions Emitron/Emitron/Downloads/DownloadProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import Foundation
import Combine
import AVFoundation

protocol DownloadProcessorModel {
var id: UUID { get }
Expand Down Expand Up @@ -56,6 +57,18 @@ private extension URLSessionDownloadTask {
}
}

private extension AVAssetDownloadTask {
var downloadId: UUID? {
get {
guard let taskDescription = taskDescription else { return .none }
return UUID(uuidString: taskDescription)
}
set {
taskDescription = newValue?.uuidString ?? ""
}
}
}

enum DownloadProcessorError: Error {
case invalidArguments
case unknownDownload
Expand All @@ -64,17 +77,21 @@ enum DownloadProcessorError: Error {
// Manage a list of files to download—either queued, in progresss, paused or failed.
final class DownloadProcessor: NSObject {
static let sessionIdentifier = "com.razeware.emitron.DownloadProcessor"
static let sdBitrate = 250_000
private var downloadQuality: Attachment.Kind {
SettingsManager.current.downloadQuality
}

private lazy var session: URLSession = {
private lazy var session: AVAssetDownloadURLSession = {
let config = URLSessionConfiguration.background(withIdentifier: DownloadProcessor.sessionIdentifier)
// Uncommenting this causes the download task to fail with POSIX 22. But seemingly only with
// Vimeo URLs. So that's handy.
// config.isDiscretionary = true
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: .none)
return AVAssetDownloadURLSession(configuration: config, assetDownloadDelegate: self, delegateQueue: .none)
}()
var backgroundSessionCompletionHandler: (() -> Void)?
private var currentDownloads = [URLSessionDownloadTask]()
private var currentDownloads = [AVAssetDownloadTask]()
private var throttleList = [UUID: Double]()
weak var delegate: DownloadProcessorDelegate!

Expand All @@ -87,8 +104,13 @@ final class DownloadProcessor: NSObject {
extension DownloadProcessor {
func add(download: DownloadProcessorModel) throws {
guard let remoteURL = download.remoteURL else { throw DownloadProcessorError.invalidArguments }

let downloadTask = session.downloadTask(with: remoteURL)
let hlsAsset = AVURLAsset(url: remoteURL)
var options: [String: Any]?
if downloadQuality == .sdVideoFile {
options = [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: DownloadProcessor.sdBitrate]
}
guard let downloadTask = session.makeAssetDownloadTask(asset: hlsAsset, assetTitle: "\(download.id))", assetArtworkData: nil, options: options) else { return }

downloadTask.downloadId = download.id
downloadTask.resume()

Expand Down Expand Up @@ -117,13 +139,15 @@ extension DownloadProcessor {
}

extension DownloadProcessor {
private func getDownloadTasksFromSession() -> [URLSessionDownloadTask] {
var tasks = [URLSessionDownloadTask]()
private func getDownloadTasksFromSession() -> [AVAssetDownloadTask] {
var tasks = [AVAssetDownloadTask]()
// Use a semaphore to make an async call synchronous
// --There's no point in trying to complete instantiating this class without this list.
let semaphore = DispatchSemaphore(value: 0)
session.getTasksWithCompletionHandler { _, _, downloadTasks in
tasks = downloadTasks
session.getAllTasks { downloadTasks in

let myTasks = downloadTasks as! [AVAssetDownloadTask]
tasks = myTasks
semaphore.signal()
}

Expand All @@ -137,6 +161,47 @@ extension DownloadProcessor {
}
}

extension DownloadProcessor: AVAssetDownloadDelegate {

func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) {

guard let downloadId = assetDownloadTask.downloadId else { return }

var percentComplete = 0.0
for value in loadedTimeRanges {
let loadedTimeRange: CMTimeRange = value.timeRangeValue
percentComplete += CMTimeGetSeconds(loadedTimeRange.duration) / CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
}

if let lastReportedProgress = throttleList[downloadId],
abs(percentComplete - lastReportedProgress) < 0.02 {
// Less than a 2% change—it's a no-op
return
}
throttleList[downloadId] = percentComplete
delegate.downloadProcessor(self, downloadWithId: downloadId, didUpdateProgress: percentComplete)
}

func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {

guard let downloadId = assetDownloadTask.downloadId,
let delegate = delegate else { return }

let download = delegate.downloadProcessor(self, downloadModelForDownloadWithId: downloadId)
guard let localURL = download?.localURL else { return }

let fileManager = FileManager.default
do {
if fileManager.fileExists(atPath: localURL.path) {
try fileManager.removeItem(at: localURL)
}
try fileManager.moveItem(at: location, to: localURL)
} catch {
delegate.downloadProcessor(self, downloadWithId: downloadId, didFailWithError: error)
}
}
}

extension DownloadProcessor: URLSessionDownloadDelegate {
// When the background session has finished sending us events, we can tell the system we're done.
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
Expand Down Expand Up @@ -181,44 +246,30 @@ extension DownloadProcessor: URLSessionDownloadDelegate {

// Use this to handle and client-side download errors
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let downloadTask = task as? URLSessionDownloadTask, let downloadId = downloadTask.downloadId else { return }


guard let downloadTask = task as? AVAssetDownloadTask, let downloadId = downloadTask.downloadId else { return }

if let error = error as NSError? {
let cancellationReason = (error.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? NSNumber)?.intValue
if cancellationReason == NSURLErrorCancelledReasonUserForceQuitApplication || cancellationReason == NSURLErrorCancelledReasonBackgroundUpdatesDisabled {
// The download was cancelled for technical reasons, but we might be able to restart it...
var newTask: URLSessionDownloadTask?
if let resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
newTask = session.downloadTask(withResumeData: resumeData)
} else {
let download = delegate.downloadProcessor(self, downloadModelForDownloadWithId: downloadId)
if let remoteURL = download?.remoteURL {
newTask = session.downloadTask(with: remoteURL)
}
}
if let newTask = newTask {
newTask.downloadId = downloadId
newTask.resume()
currentDownloads.append(newTask)
delegate.downloadProcessor(self, didStartDownloadWithId: downloadId)
}


currentDownloads.removeAll { $0 == downloadTask }
} else if error.code == NSURLErrorCancelled {
// User-requested cancellation
currentDownloads.removeAll { $0 == downloadTask }

delegate.downloadProcessor(self, didCancelDownloadWithId: downloadId)
} else {
// Unknown error
currentDownloads.removeAll { $0 == downloadTask }

delegate.downloadProcessor(self, downloadWithId: downloadId, didFailWithError: error)
}
} else {
// Success!
currentDownloads.removeAll { $0 == downloadTask }

delegate.downloadProcessor(self, didFinishDownloadWithId: downloadId)
}
}
Expand Down
15 changes: 7 additions & 8 deletions Emitron/Emitron/Downloads/DownloadService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,28 +299,28 @@ extension DownloadService {
}

// Use the video service to request the URLs
videosService.getVideoDownload(for: videoId) { [weak self] result in
videosService.getVideoStreamDownload(for: videoId) { [weak self] result in
// Ensure we're still around
guard let self = self else { return }
var download = downloadQueueItem.download

switch result {
case .failure(let error):
Failure
.downloadService(from: "requestDownloadURL",
reason: "Unable to obtain download URLs: \(error)")
.log()
case .success(let attachments):
download.remoteURL = attachments.first { $0.kind == self.downloadQuality }?.url
case .success(let attachment):
download.remoteURL = attachment.url
download.lastValidatedAt = Date()
download.state = .readyForDownload
}

// Update the state if required
if download.remoteURL == nil {
download.state = .error
}

// Commit the changes
do {
try self.persistenceStore.update(download: download)
Expand All @@ -332,7 +332,6 @@ extension DownloadService {
self.transitionDownload(withID: download.id, to: .failed)
}
}

// Move it on through the state machine
transitionDownload(withID: downloadQueueItem.download.id, to: .urlRequested)
}
Expand All @@ -356,7 +355,7 @@ extension DownloadService {
}

// Generate filename
let filename = "\(videoId).mp4"
let filename = "\(videoId).m3u8"

// Save local URL and filename
var download = downloadQueueItem.download
Expand Down
18 changes: 12 additions & 6 deletions Emitron/Emitron/Networking/Requests/VideosRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,29 @@ struct StreamVideoRequest: Request {
}
}

struct DownloadVideoRequest: Request {
// It contains two Attachment objects, one for the HD file and one for the SD file.
typealias Response = [Attachment]
struct DownloadStreamVideoRequest: Request {
typealias Response = Attachment

// MARK: - Properties
var method: HTTPMethod { .GET }
var path: String { "/videos/\(id)/download" }
var path: String { "/videos/\(id)/stream" }
var additionalHeaders: [String: String] = [:]
var body: Data? { nil }

// MARK: - Parameters
let id: Int

// MARK: - Internal
func handle(response: Data) throws -> [Attachment] {
func handle(response: Data) throws -> Attachment {
let json = try JSON(data: response)
let doc = JSONAPIDocument(json)
return try doc.data.map { try AttachmentAdapter.process(resource: $0) }
let attachments = try doc.data.map { try AttachmentAdapter.process(resource: $0) }

guard let attachment = attachments.first,
attachments.count == 1 else {
throw RWAPIError.responseHasIncorrectNumberOfElements
}

return attachment
}
}
1 change: 0 additions & 1 deletion Emitron/Emitron/Networking/Services/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ class Service {
func prepare<R: Request>(request: R,
parameters: [Parameter]?) -> URLRequest? {
let pathURL = networkClient.environment.baseURL.appendingPathComponent(request.path)

var components = URLComponents(url: pathURL,
resolvingAgainstBaseURL: false)

Expand Down
6 changes: 3 additions & 3 deletions Emitron/Emitron/Networking/Services/VideosService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ class VideosService: Service {
completion: completion)
}

func getVideoDownload(for id: Int,
completion: @escaping (_ response: Result<DownloadVideoRequest.Response, RWAPIError>) -> Void) {
let request = DownloadVideoRequest(id: id)
func getVideoStreamDownload(for id: Int,
completion: @escaping (_ response: Result<StreamVideoRequest.Response, RWAPIError>) -> Void) {
let request = DownloadStreamVideoRequest(id: id)
makeAndProcessRequest(request: request,
completion: completion)
}
Expand Down
2 changes: 0 additions & 2 deletions Emitron/Emitron/Sessions/SessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,6 @@ class SessionController: NSObject, UserModelController, ObservablePrePostFactoOb
.log()
case .success(let user):
self.user = user
print(user)

Event
.login(from: "SessionController")
.log()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ private extension ChildContentListingView {
.kerning(-0.5)
.foregroundColor(.titleText)
.padding([.top, .bottom])
Button {
exit(0)
} label: {
Text("Close App0")
}
Spacer()
}.padding([.leading, .trailing], 20)
}
Expand Down
Loading