diff --git a/Emitron/Emitron/AppDelegate.swift b/Emitron/Emitron/AppDelegate.swift index a83bbcdf..b9684e7d 100644 --- a/Emitron/Emitron/AppDelegate.swift +++ b/Emitron/Emitron/AppDelegate.swift @@ -76,7 +76,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { persistenceStore: persistenceStore, downloadService: downloadService ) - downloadService.startProcessing() + downloadService.startProcessing() } // MARK: UISceneSession Lifecycle diff --git a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift index 15500e7e..747ee44c 100644 --- a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift @@ -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 diff --git a/Emitron/Emitron/Downloads/DownloadProcessor.swift b/Emitron/Emitron/Downloads/DownloadProcessor.swift index 0056f446..1a0d9eb5 100644 --- a/Emitron/Emitron/Downloads/DownloadProcessor.swift +++ b/Emitron/Emitron/Downloads/DownloadProcessor.swift @@ -28,6 +28,7 @@ import Foundation import Combine +import AVFoundation protocol DownloadProcessorModel { var id: UUID { get } @@ -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 @@ -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! @@ -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() @@ -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() } @@ -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) { @@ -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) } } diff --git a/Emitron/Emitron/Downloads/DownloadService.swift b/Emitron/Emitron/Downloads/DownloadService.swift index dcf7ba41..8d01c6bc 100644 --- a/Emitron/Emitron/Downloads/DownloadService.swift +++ b/Emitron/Emitron/Downloads/DownloadService.swift @@ -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) @@ -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) } @@ -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 diff --git a/Emitron/Emitron/Networking/Requests/VideosRequest.swift b/Emitron/Emitron/Networking/Requests/VideosRequest.swift index 51938daf..3d7455c5 100644 --- a/Emitron/Emitron/Networking/Requests/VideosRequest.swift +++ b/Emitron/Emitron/Networking/Requests/VideosRequest.swift @@ -56,13 +56,12 @@ 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 } @@ -70,9 +69,16 @@ struct DownloadVideoRequest: Request { 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 } } diff --git a/Emitron/Emitron/Networking/Services/Service.swift b/Emitron/Emitron/Networking/Services/Service.swift index b13df6cd..3c21003a 100644 --- a/Emitron/Emitron/Networking/Services/Service.swift +++ b/Emitron/Emitron/Networking/Services/Service.swift @@ -85,7 +85,6 @@ class Service { func prepare(request: R, parameters: [Parameter]?) -> URLRequest? { let pathURL = networkClient.environment.baseURL.appendingPathComponent(request.path) - var components = URLComponents(url: pathURL, resolvingAgainstBaseURL: false) diff --git a/Emitron/Emitron/Networking/Services/VideosService.swift b/Emitron/Emitron/Networking/Services/VideosService.swift index 25f223ed..37278619 100644 --- a/Emitron/Emitron/Networking/Services/VideosService.swift +++ b/Emitron/Emitron/Networking/Services/VideosService.swift @@ -37,9 +37,9 @@ class VideosService: Service { completion: completion) } - func getVideoDownload(for id: Int, - completion: @escaping (_ response: Result) -> Void) { - let request = DownloadVideoRequest(id: id) + func getVideoStreamDownload(for id: Int, + completion: @escaping (_ response: Result) -> Void) { + let request = DownloadStreamVideoRequest(id: id) makeAndProcessRequest(request: request, completion: completion) } diff --git a/Emitron/Emitron/Sessions/SessionController.swift b/Emitron/Emitron/Sessions/SessionController.swift index 6130f0ba..ade8a452 100644 --- a/Emitron/Emitron/Sessions/SessionController.swift +++ b/Emitron/Emitron/Sessions/SessionController.swift @@ -144,8 +144,6 @@ class SessionController: NSObject, UserModelController, ObservablePrePostFactoOb .log() case .success(let user): self.user = user - print(user) - Event .login(from: "SessionController") .log() diff --git a/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift b/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift index 092585a3..1bd0b16b 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift @@ -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) } diff --git a/Emitron/emitronTests/Downloads/DownloadServiceTest.swift b/Emitron/emitronTests/Downloads/DownloadServiceTest.swift index 56df50a2..e0ee7bf1 100644 --- a/Emitron/emitronTests/Downloads/DownloadServiceTest.swift +++ b/Emitron/emitronTests/Downloads/DownloadServiceTest.swift @@ -600,34 +600,6 @@ class DownloadServiceTest: XCTestCase { } } - func testRequestDownloadURLRespectsTheUserPreferencesOnQuality() throws { - let downloadQueueItem = try sampleDownloadQueueItem() - let attachment = AttachmentTest.Mocks.downloads.0.first { $0.kind == .sdVideoFile }! - - SettingsManager.current.downloadQuality = .sdVideoFile - - downloadService.requestDownloadURL(downloadQueueItem) - - try database.read { db in - let download = try Download.fetchOne(db, key: downloadQueueItem.download.id)! - XCTAssertNotNil(download.remoteURL) - XCTAssertEqual(attachment.url, download.remoteURL) - } - } - - func testRequestDownloadDefaultsToHDQuality() throws { - let downloadQueueItem = try sampleDownloadQueueItem() - let attachment = AttachmentTest.Mocks.downloads.0.first { $0.kind == .hdVideoFile }! - - downloadService.requestDownloadURL(downloadQueueItem) - - try database.read { db in - let download = try Download.fetchOne(db, key: downloadQueueItem.download.id)! - XCTAssertNotNil(download.remoteURL) - XCTAssertEqual(attachment.url, download.remoteURL) - } - } - func testRequestDownloadUpdatesTheStateCorrectly() throws { let downloadQueueItem = try sampleDownloadQueueItem() @@ -660,41 +632,6 @@ class DownloadServiceTest: XCTestCase { } } - func testEnqueueUpdatesStateToCompletedIfItFindsDownload() throws { - let downloadQueueItem = try sampleDownloadQueueItem() - var download = downloadQueueItem.download - download.remoteURL = URL(string: "https://example.com/amazing.mp4") - download.fileName = "\(downloadQueueItem.content.videoIdentifier!).mp4" - download.state = .readyForDownload - try database.write { db in - try download.save(db) - } - - let fileManager = FileManager.default - let documentsDirectories = fileManager.urls(for: .documentDirectory, in: .userDomainMask) - let documentsDirectory = documentsDirectories.first - let downloadsDirectory = documentsDirectory!.appendingPathComponent("downloads", isDirectory: true) - - let sampleFile = downloadsDirectory.appendingPathComponent(download.fileName!) - - XCTAssert(!fileManager.fileExists(atPath: sampleFile.path)) - - fileManager.createFile(atPath: sampleFile.path, contents: nil) - - XCTAssert(fileManager.fileExists(atPath: sampleFile.path)) - - let newQueueItem = PersistenceStore.DownloadQueueItem(download: download, content: downloadQueueItem.content) - downloadService.enqueue(downloadQueueItem: newQueueItem) - - try database.read { db in - let refreshedDownload = try Download.fetchOne(db, key: download.id)! - XCTAssertEqual(Download.State.complete, refreshedDownload.state) - XCTAssertEqual(sampleFile, refreshedDownload.localURL) - } - - try fileManager.removeItem(at: sampleFile) - } - func testEnqueueDoesNothingForADownloadWithoutARemoteURL() throws { let downloadQueueItem = try sampleDownloadQueueItem() var download = downloadQueueItem.download diff --git a/Emitron/emitronTests/Models/AttachmentTest+Mocks.swift b/Emitron/emitronTests/Models/AttachmentTest+Mocks.swift index ac34fc59..7ff4de0a 100644 --- a/Emitron/emitronTests/Models/AttachmentTest+Mocks.swift +++ b/Emitron/emitronTests/Models/AttachmentTest+Mocks.swift @@ -32,16 +32,16 @@ import SwiftyJSON extension AttachmentTest { enum Mocks { - static var downloads: ([Attachment], DataCacheUpdate) { + static var download: (Attachment, DataCacheUpdate) { loadMockFrom(filename: "Attachment_Downloads") } static var stream: (Attachment, DataCacheUpdate) { - let (attachments, cacheUpdate) = loadMockFrom(filename: "Attachment_Stream") - return (attachments.first!, cacheUpdate) + let (attachment, cacheUpdate) = loadMockFrom(filename: "Attachment_Stream") + return (attachment, cacheUpdate) } - private static func loadMockFrom(filename: String) -> ([Attachment], DataCacheUpdate) { + private static func loadMockFrom(filename: String) -> (Attachment, DataCacheUpdate) { do { let bundle = Bundle(for: AttachmentTest.self) let fileURL = bundle.url(forResource: filename, withExtension: "json") @@ -53,7 +53,7 @@ extension AttachmentTest { try AttachmentAdapter.process(resource: resource) } let cacheUpdate = try DataCacheUpdate.loadFrom(document: document) - return (attachments, cacheUpdate) + return (attachments[0], cacheUpdate) } catch { preconditionFailure("Unable to load Attachment mock: \(error)") } diff --git a/Emitron/emitronTests/Models/Mocks/Attachment_Downloads.json b/Emitron/emitronTests/Models/Mocks/Attachment_Downloads.json index a7eb5ad6..046a5597 100644 --- a/Emitron/emitronTests/Models/Mocks/Attachment_Downloads.json +++ b/Emitron/emitronTests/Models/Mocks/Attachment_Downloads.json @@ -1,13 +1,5 @@ { "data":[ - { - "id":"30194", - "type":"attachments", - "attributes":{ - "url":"https://player.vimeo.com/external/332761683.sd.mp4?s=263eeb3fa49a8e8a5fea81c267bec4071d08bc78\u0026profile_id=164\u0026oauth2_token_id=897711146", - "kind":"sd_video_file" - } - }, { "id":"30195", "type":"attachments", diff --git a/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift b/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift index 63e5df33..0fa9aaef 100644 --- a/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift +++ b/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift @@ -47,8 +47,8 @@ class VideosServiceMock: VideosService { getVideoStreamCount += 1 } - override func getVideoDownload(for id: Int, completion: @escaping (Result) -> Void) { + override func getVideoStreamDownload(for id: Int, completion: @escaping (Result) -> Void) { getVideoDownloadCount += 1 - completion(Result.success(AttachmentTest.Mocks.downloads.0)) + completion(Result.success(AttachmentTest.Mocks.download.0)) } }