Basic download manager

This commit is contained in:
Guilherme Rambo
2017-05-07 11:25:01 -03:00
parent bad95632c0
commit 8f6c9688c6
8 changed files with 198 additions and 33 deletions

View File

@@ -11,6 +11,7 @@ import RealmSwift
public enum DownloadStatus: String {
case none
case deleted
case downloading
case paused
case failed

View File

@@ -24,7 +24,18 @@ public final class Storage {
self.realm = try Realm(configuration: configuration)
}
func store(sessionsResult: Result<SessionsResponse, APIError>, scheduleResult: Result<ScheduleResponse, APIError>) {
/// Performs a write transaction on the database using `block`
public func update(with block: @escaping () -> Void) {
do {
try realm.write {
block()
}
} catch {
NSLog("Error performing realm write: \(error)")
}
}
func store(sessionsResult: Result<SessionsResponse, APIError>, scheduleResult: Result<ScheduleResponse, APIError>, completion: @escaping () -> Void) {
backgroundQueue.async {
if self.backgroundRealm == nil {
do {
@@ -97,6 +108,8 @@ public final class Storage {
do {
try self.backgroundRealm.commitWrite()
DispatchQueue.main.async { completion() }
} catch {
NSLog("Realm error: \(error)")
}
@@ -109,10 +122,14 @@ public final class Storage {
return Observable.collection(from: eventsSortedByDateDescending)
}()
public lazy var sessions: Observable<Results<Session>> = {
public lazy var sessionsObservable: Observable<Results<Session>> = {
return Observable.collection(from: self.realm.objects(Session.self))
}()
public var sessions: Results<Session> {
return self.realm.objects(Session.self).filter("assets.@count > 0")
}
public func session(with identifier: String) -> Session? {
return realm.object(ofType: Session.self, forPrimaryKey: identifier)
}
@@ -139,4 +156,29 @@ public final class Storage {
}
}
public lazy var activeDownloads: Observable<Results<Download>> = {
let results = self.realm.objects(Download.self).filter("rawStatus != %@ AND rawStatus != %@", "none", "deleted")
return Observable.collection(from: results)
}()
public func createDownload(for asset: SessionAsset) {
// prevent multiple download instances per session asset
guard asset.downloads.filter({ $0.status != .deleted && $0.status != .none }).count == 0 else { return }
do {
try realm.write {
let download = Download()
download.status = .paused
asset.downloads.append(download)
}
} catch {
NSLog("Error creating download for asset \(asset): \(error)")
}
}
public func asset(with remoteURL: URL) -> SessionAsset? {
return realm.objects(SessionAsset.self).filter("remoteURL == %@", remoteURL.absoluteString).first
}
}

View File

@@ -9,6 +9,10 @@
import Foundation
import RxSwift
extension Notification.Name {
public static let SyncEngineDidSyncSessionsAndSchedule = Notification.Name("SyncEngineDidSyncSessionsAndSchedule")
}
public final class SyncEngine {
public let storage: Storage
@@ -19,12 +23,14 @@ public final class SyncEngine {
self.client = client
}
public func syncSessionsAndSchedule(completion: @escaping (APIError?) -> Void) {
public func syncSessionsAndSchedule() {
client.fetchSessions { [weak self] sessionsResult in
DispatchQueue.main.async {
self?.client.fetchSchedule { scheduleResult in
DispatchQueue.main.async {
self?.storage.store(sessionsResult: sessionsResult, scheduleResult: scheduleResult)
self?.storage.store(sessionsResult: sessionsResult, scheduleResult: scheduleResult) {
NotificationCenter.default.post(name: .SyncEngineDidSyncSessionsAndSchedule, object: self)
}
}
}
}

View File

@@ -127,6 +127,7 @@
DDF32EBB1EBE65DD0028E39D /* AppCoordinator+SessionActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF32EBA1EBE65DD0028E39D /* AppCoordinator+SessionActions.swift */; };
DDF32EBF1EBE68EE0028E39D /* NSTableView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF32EBE1EBE68EE0028E39D /* NSTableView+Rx.swift */; };
DDFA10BD1EBEA584001DCF66 /* Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFA10BC1EBEA584001DCF66 /* Download.swift */; };
DDFA10BF1EBEAAAD001DCF66 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFA10BE1EBEAAAD001DCF66 /* DownloadManager.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -326,6 +327,7 @@
DDF32EBA1EBE65DD0028E39D /* AppCoordinator+SessionActions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppCoordinator+SessionActions.swift"; sourceTree = "<group>"; };
DDF32EBE1EBE68EE0028E39D /* NSTableView+Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSTableView+Rx.swift"; sourceTree = "<group>"; };
DDFA10BC1EBEA584001DCF66 /* Download.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Download.swift; sourceTree = "<group>"; };
DDFA10BE1EBEAAAD001DCF66 /* DownloadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -423,6 +425,7 @@
DDF32EB81EBE65B50028E39D /* AppCoordinator+Shelf.swift */,
DDF32EB61EBE65930028E39D /* AppCoordinator+UserActivity.swift */,
DDF32EBA1EBE65DD0028E39D /* AppCoordinator+SessionActions.swift */,
DDFA10BE1EBEAAAD001DCF66 /* DownloadManager.swift */,
);
name = Bootstrap;
sourceTree = "<group>";
@@ -993,6 +996,7 @@
DD7F38761EABFB20002D8C00 /* NSColor+Hex.swift in Sources */,
DDB28F931EAD48D70077703F /* UserActivityRepresentable.swift in Sources */,
DD7F38801EAC15B4002D8C00 /* RxNil.swift in Sources */,
DDFA10BF1EBEAAAD001DCF66 /* DownloadManager.swift in Sources */,
DD7F38881EAC2275002D8C00 /* PathUtil.swift in Sources */,
DD7F386A1EABE996002D8C00 /* SessionTableCellView.swift in Sources */,
DD36A4B21E478C6A00B2EA88 /* VideosSplitViewController.swift in Sources */,

View File

@@ -25,7 +25,11 @@ extension AppCoordinator: SessionActionsViewControllerDelegate {
}
func sessionActionsDidSelectDownload(_ sender: NSView?) {
guard let viewModel = selectedSessionValue else { return }
guard let videoAsset = viewModel.session.assets.filter({ $0.assetType == .hdVideo }).first else { return }
downloadManager.download(videoAsset)
}
func sessionActionsDidSelectShare(_ sender: NSView?) {

View File

@@ -18,6 +18,7 @@ final class AppCoordinator {
var storage: Storage
var syncEngine: SyncEngine
var downloadManager: DownloadManager
var windowController: MainWindowController
var tabController: MainTabController
@@ -40,6 +41,8 @@ final class AppCoordinator {
self.storage = try Storage(realmConfig)
self.syncEngine = SyncEngine(storage: storage, client: client)
self.downloadManager = DownloadManager(storage)
} catch {
fatalError("Realm initialization error: \(error)")
}
@@ -69,7 +72,6 @@ final class AppCoordinator {
}
private func setupBindings() {
storage.sessions.bind(to: videosController.listViewController.sessions).addDisposableTo(self.disposeBag)
selectedSession.bind(to: videosController.detailViewController.viewModel).addDisposableTo(self.disposeBag)
selectedSession.subscribe(onNext: updateCurrentActivity).addDisposableTo(self.disposeBag)
@@ -82,19 +84,22 @@ final class AppCoordinator {
detail.summaryController.actionsViewController.delegate = self
}
private func updateListsAfterSync() {
self.videosController.listViewController.sessions = storage.sessions
}
@IBAction func refresh(_ sender: Any?) {
syncEngine.syncSessionsAndSchedule { error in
if let error = error {
// TODO: better error handling
print("Error while syncing sessions and schedule: \(error)")
}
}
syncEngine.syncSessionsAndSchedule()
}
func startup() {
windowController.contentViewController = tabController
windowController.showWindow(self)
NotificationCenter.default.addObserver(forName: .SyncEngineDidSyncSessionsAndSchedule, object: nil, queue: OperationQueue.main) { _ in
self.updateListsAfterSync()
}
refresh(nil)
}

View File

@@ -0,0 +1,95 @@
//
// DownloadManager.swift
// WWDC
//
// Created by Guilherme Rambo on 06/05/17.
// Copyright © 2017 Guilherme Rambo. All rights reserved.
//
import Cocoa
import RxSwift
import ConfCore
final class DownloadManager: NSObject {
fileprivate let storage: Storage
fileprivate var tasks: [String: URLSessionDownloadTask] = [:]
fileprivate lazy var downloadSession: URLSession = {
let s = URLSession(configuration: .default, delegate: self, delegateQueue: OperationQueue.main)
return s
}()
init(_ storage: Storage) {
self.storage = storage
}
func download(_ asset: SessionAsset) {
guard let url = URL(string: asset.remoteURL) else { return }
storage.createDownload(for: asset)
let task = downloadSession.downloadTask(with: url)
self.tasks[asset.remoteURL] = task
task.resume()
}
}
extension DownloadManager: URLSessionDelegate, URLSessionDownloadDelegate, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
guard let url = downloadTask.originalRequest?.url else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
let download = storage.asset(with: url)?.downloads.first
storage.update {
download?.status = .downloading
download?.progress = progress
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
guard let url = downloadTask.originalRequest?.url else { return }
guard let asset = storage.asset(with: url) else { return }
guard let dir = NSSearchPathForDirectoriesInDomains(.moviesDirectory, .userDomainMask, true).first else { return }
let finalPath = dir + "/" + asset.relativeLocalURL
let download = asset.downloads.first
do {
let finalURL = URL(fileURLWithPath: finalPath)
let finalDirURL = finalURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: finalDirURL, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.moveItem(at: location, to: finalURL)
storage.update {
download?.status = .completed
download?.progress = 1
}
} catch {
NSLog("Error copying file downloaded for \(asset): \(error)")
storage.update {
download?.status = .failed
}
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let url = task.originalRequest?.url else { return }
let download = storage.asset(with: url)?.downloads.first
storage.update {
download?.status = error != nil ? .failed : .completed
}
}
}

View File

@@ -21,7 +21,13 @@ class VideosTableViewController: NSViewController {
private let disposeBag = DisposeBag()
var sessions = Variable<Results<Session>?>(nil)
var sessions: Results<Session>? {
didSet {
guard oldValue?.count != sessions?.count else { return }
updateSessionsList()
}
}
var selectedSession = Variable<SessionViewModel?>(nil)
var viewModels: [SessionRow] = [] {
@@ -45,6 +51,28 @@ class VideosTableViewController: NSViewController {
fatalError("init(coder:) has not been implemented")
}
private func updateSessionsList() {
guard let results = sessions else { return }
let sortedSessions = results.sorted(by: Session.standardSort)
var outViewModels: [SessionRow] = []
let rowModels = sortedSessions.flatMap(SessionViewModel.init(session:)).map(SessionRow.init(viewModel:))
var previousRowModel: SessionRow? = nil
for rowModel in rowModels {
if rowModel.viewModel.trackName != previousRowModel?.viewModel.trackName {
outViewModels.append(SessionRow(title: rowModel.viewModel.trackName))
}
outViewModels.append(rowModel)
previousRowModel = rowModel
}
self.viewModels = rowModels
}
lazy var tableView: NSTableView = {
let v = NSTableView()
@@ -102,27 +130,7 @@ class VideosTableViewController: NSViewController {
return self.viewModels[index].viewModel
}.bind(to: selectedSession).addDisposableTo(self.disposeBag)
sessions.asObservable().subscribe(onNext: { [weak self] results in
guard let results = results else { return }
let sortedSessions = results.filter(NSPredicate(format: "assets.@count > 0")).sorted(by: Session.standardSort)
var outViewModels: [SessionRow] = []
let rowModels = sortedSessions.flatMap(SessionViewModel.init(session:)).map(SessionRow.init(viewModel:))
var previousRowModel: SessionRow? = nil
for rowModel in rowModels {
if rowModel.viewModel.trackName != previousRowModel?.viewModel.trackName {
outViewModels.append(SessionRow(title: rowModel.viewModel.trackName))
}
outViewModels.append(rowModel)
previousRowModel = rowModel
}
self?.viewModels = outViewModels
}).addDisposableTo(self.disposeBag)
}
}