From 235114d20d9b05356fd98af253850cb580152922 Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Mon, 3 Jun 2024 18:20:15 -0300 Subject: [PATCH 01/12] Pulled in media download code from prototype --- .swiftlint.yml | 1 + WWDC.xcodeproj/project.pbxproj | 72 +++ .../Engines/AVAssetMediaDownloadEngine.swift | 258 ++++++++ .../SimulatedMediaDownloadEngine.swift | 255 ++++++++ .../URLSessionMediaDownloadEngine.swift | 202 +++++++ .../FSMediaDownloadMetadataStore.swift | 99 ++++ WWDC/MediaDownload/MediaDownload.swift | 245 ++++++++ WWDC/MediaDownload/MediaDownloadManager.swift | 557 ++++++++++++++++++ .../MediaDownloadProtocols.swift | 105 ++++ .../Support/Bundle+URLSessionID.swift | 8 + .../Support/PreviewAndTestingSupport.swift | 104 ++++ WWDC/MediaDownload/Support/String+Error.swift | 5 + .../Support/URL+FileHelpers.swift | 39 ++ .../Support/URLSessionTask+Media.swift | 14 + 14 files changed, 1964 insertions(+) create mode 100644 WWDC/MediaDownload/Engines/AVAssetMediaDownloadEngine.swift create mode 100644 WWDC/MediaDownload/Engines/SimulatedMediaDownloadEngine.swift create mode 100644 WWDC/MediaDownload/Engines/URLSessionMediaDownloadEngine.swift create mode 100644 WWDC/MediaDownload/FSMediaDownloadMetadataStore.swift create mode 100644 WWDC/MediaDownload/MediaDownload.swift create mode 100644 WWDC/MediaDownload/MediaDownloadManager.swift create mode 100644 WWDC/MediaDownload/MediaDownloadProtocols.swift create mode 100644 WWDC/MediaDownload/Support/Bundle+URLSessionID.swift create mode 100644 WWDC/MediaDownload/Support/PreviewAndTestingSupport.swift create mode 100644 WWDC/MediaDownload/Support/String+Error.swift create mode 100644 WWDC/MediaDownload/Support/URL+FileHelpers.swift create mode 100644 WWDC/MediaDownload/Support/URLSessionTask+Media.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 9323bede..474d4cbe 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -14,6 +14,7 @@ disabled_rules: - implicit_getter - for_where - opening_brace + - vertical_parameter_alignment opt_in_rules: - redundant_nil_coalescing diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index 5014dbef..dc6ec708 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -186,6 +186,18 @@ F474DEC926737EFA00B28B31 /* SharePlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474DEC826737EFA00B28B31 /* SharePlayManager.swift */; }; F474DECD2673801500B28B31 /* WatchWWDCActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474DECC2673801500B28B31 /* WatchWWDCActivity.swift */; }; F4777ABA2A2A2F6C00A09179 /* WWDCAgentRemover.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4777AB92A2A2F6C00A09179 /* WWDCAgentRemover.swift */; }; + F486B3072C0E69E60066749F /* MediaDownloadProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B2F82C0E69E60066749F /* MediaDownloadProtocols.swift */; }; + F486B3082C0E69E60066749F /* FSMediaDownloadMetadataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B2F92C0E69E60066749F /* FSMediaDownloadMetadataStore.swift */; }; + F486B3092C0E69E60066749F /* MediaDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B2FA2C0E69E60066749F /* MediaDownloadManager.swift */; }; + F486B30A2C0E69E60066749F /* AVAssetMediaDownloadEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B2FB2C0E69E60066749F /* AVAssetMediaDownloadEngine.swift */; }; + F486B30B2C0E69E60066749F /* SimulatedMediaDownloadEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B2FC2C0E69E60066749F /* SimulatedMediaDownloadEngine.swift */; }; + F486B30C2C0E69E60066749F /* URLSessionMediaDownloadEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B2FD2C0E69E60066749F /* URLSessionMediaDownloadEngine.swift */; }; + F486B30D2C0E69E60066749F /* Bundle+URLSessionID.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B2FF2C0E69E60066749F /* Bundle+URLSessionID.swift */; }; + F486B30F2C0E69E60066749F /* PreviewAndTestingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B3012C0E69E60066749F /* PreviewAndTestingSupport.swift */; }; + F486B3102C0E69E60066749F /* String+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B3022C0E69E60066749F /* String+Error.swift */; }; + F486B3112C0E69E60066749F /* URL+FileHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B3032C0E69E60066749F /* URL+FileHelpers.swift */; }; + F486B3122C0E69E60066749F /* URLSessionTask+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B3042C0E69E60066749F /* URLSessionTask+Media.swift */; }; + F486B3132C0E69E60066749F /* MediaDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F486B3062C0E69E60066749F /* MediaDownload.swift */; }; F4A882842673AC8500BAB7F5 /* TitleBarButtonsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A882832673AC8500BAB7F5 /* TitleBarButtonsViewController.swift */; }; F4A882882673AD2D00BAB7F5 /* SharePlayStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A882862673AD2D00BAB7F5 /* SharePlayStatusView.swift */; }; F4CCF942265ED24500A69E62 /* AppCommandsReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4CCF941265ED24500A69E62 /* AppCommandsReceiver.swift */; }; @@ -458,6 +470,18 @@ F474DEC826737EFA00B28B31 /* SharePlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayManager.swift; sourceTree = ""; }; F474DECC2673801500B28B31 /* WatchWWDCActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchWWDCActivity.swift; sourceTree = ""; }; F4777AB92A2A2F6C00A09179 /* WWDCAgentRemover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WWDCAgentRemover.swift; sourceTree = ""; }; + F486B2F82C0E69E60066749F /* MediaDownloadProtocols.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaDownloadProtocols.swift; sourceTree = ""; }; + F486B2F92C0E69E60066749F /* FSMediaDownloadMetadataStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FSMediaDownloadMetadataStore.swift; sourceTree = ""; }; + F486B2FA2C0E69E60066749F /* MediaDownloadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaDownloadManager.swift; sourceTree = ""; }; + F486B2FB2C0E69E60066749F /* AVAssetMediaDownloadEngine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AVAssetMediaDownloadEngine.swift; sourceTree = ""; }; + F486B2FC2C0E69E60066749F /* SimulatedMediaDownloadEngine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimulatedMediaDownloadEngine.swift; sourceTree = ""; }; + F486B2FD2C0E69E60066749F /* URLSessionMediaDownloadEngine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionMediaDownloadEngine.swift; sourceTree = ""; }; + F486B2FF2C0E69E60066749F /* Bundle+URLSessionID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bundle+URLSessionID.swift"; sourceTree = ""; }; + F486B3012C0E69E60066749F /* PreviewAndTestingSupport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviewAndTestingSupport.swift; sourceTree = ""; }; + F486B3022C0E69E60066749F /* String+Error.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Error.swift"; sourceTree = ""; }; + F486B3032C0E69E60066749F /* URL+FileHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+FileHelpers.swift"; sourceTree = ""; }; + F486B3042C0E69E60066749F /* URLSessionTask+Media.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLSessionTask+Media.swift"; sourceTree = ""; }; + F486B3062C0E69E60066749F /* MediaDownload.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaDownload.swift; sourceTree = ""; }; F4A882832673AC8500BAB7F5 /* TitleBarButtonsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleBarButtonsViewController.swift; sourceTree = ""; }; F4A882862673AD2D00BAB7F5 /* SharePlayStatusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SharePlayStatusView.swift; sourceTree = ""; }; F4CCF941265ED24500A69E62 /* AppCommandsReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommandsReceiver.swift; sourceTree = ""; }; @@ -592,6 +616,7 @@ DD36A4C11E478CFC00B2EA88 /* ViewModels */, DD36A4C01E478CF500B2EA88 /* Views */, DD36A4BF1E478CF100B2EA88 /* Controllers */, + F486B2F72C0E69A60066749F /* MediaDownload */, ); path = WWDC; sourceTree = ""; @@ -1100,6 +1125,41 @@ name = Models; sourceTree = ""; }; + F486B2F72C0E69A60066749F /* MediaDownload */ = { + isa = PBXGroup; + children = ( + F486B3052C0E69E60066749F /* Support */, + F486B2FE2C0E69E60066749F /* Engines */, + F486B2F92C0E69E60066749F /* FSMediaDownloadMetadataStore.swift */, + F486B3062C0E69E60066749F /* MediaDownload.swift */, + F486B2FA2C0E69E60066749F /* MediaDownloadManager.swift */, + F486B2F82C0E69E60066749F /* MediaDownloadProtocols.swift */, + ); + path = MediaDownload; + sourceTree = ""; + }; + F486B2FE2C0E69E60066749F /* Engines */ = { + isa = PBXGroup; + children = ( + F486B2FB2C0E69E60066749F /* AVAssetMediaDownloadEngine.swift */, + F486B2FC2C0E69E60066749F /* SimulatedMediaDownloadEngine.swift */, + F486B2FD2C0E69E60066749F /* URLSessionMediaDownloadEngine.swift */, + ); + path = Engines; + sourceTree = ""; + }; + F486B3052C0E69E60066749F /* Support */ = { + isa = PBXGroup; + children = ( + F486B2FF2C0E69E60066749F /* Bundle+URLSessionID.swift */, + F486B3012C0E69E60066749F /* PreviewAndTestingSupport.swift */, + F486B3022C0E69E60066749F /* String+Error.swift */, + F486B3032C0E69E60066749F /* URL+FileHelpers.swift */, + F486B3042C0E69E60066749F /* URLSessionTask+Media.swift */, + ); + path = Support; + sourceTree = ""; + }; F4A882822673AC4800BAB7F5 /* TitleBar */ = { isa = PBXGroup; children = ( @@ -1395,10 +1455,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F486B30A2C0E69E60066749F /* AVAssetMediaDownloadEngine.swift in Sources */, DDB28F8E1EAD257B0077703F /* PlaybackViewModel.swift in Sources */, DD4648491ECA5EC0005C57C6 /* NSToolbarItemViewer+Overrides.m in Sources */, DDF32EAB1EBE2E240028E39D /* WWDCTableRowView.swift in Sources */, DD7E2902247FEA3900A58370 /* EventHeroViewController.swift in Sources */, + F486B30C2C0E69E60066749F /* URLSessionMediaDownloadEngine.swift in Sources */, DDC927FE20B7A259004C784D /* NSImage+Compression.swift in Sources */, F4777ABA2A2A2F6C00A09179 /* WWDCAgentRemover.swift in Sources */, DD7A2103218111470052FD07 /* WWDCProgressIndicator.swift in Sources */, @@ -1411,6 +1473,7 @@ F4578D572A2659B3005B311A /* ExploreTabItemView.swift in Sources */, DDB28F861EAD20A10077703F /* UIDebugger.m in Sources */, F4CCF942265ED24500A69E62 /* AppCommandsReceiver.swift in Sources */, + F486B30B2C0E69E60066749F /* SimulatedMediaDownloadEngine.swift in Sources */, DDB3529A1EC8AB2800254815 /* WWDCImageView.swift in Sources */, DD3D14F62486C91F00FCBBBD /* ClipRenderer.swift in Sources */, DDD930761ED52BD800D61BE3 /* DTFolderMonitor.m in Sources */, @@ -1426,6 +1489,7 @@ DDAE001D1EC534BF0036C7E9 /* TrackColorView.swift in Sources */, DD0159D91ED11A9800F980F1 /* ModalLoadingView.swift in Sources */, F44C82352A22921300FDE980 /* ExploreTabProvider.swift in Sources */, + F486B3122C0E69E60066749F /* URLSessionTask+Media.swift in Sources */, DDF32EB31EBE5C4D0028E39D /* SessionActionsViewController.swift in Sources */, DDA7B7352484416B00F86668 /* CompositionalLayoutBackgroundSwizzler.m in Sources */, F4578D5B2A2659F0005B311A /* VideoPlayer.swift in Sources */, @@ -1437,6 +1501,7 @@ F4FB06C12A2178C000799F84 /* WWDCWindowContentViewController.swift in Sources */, DDEDFCF11ED927A4002477C8 /* ToggleFilter.swift in Sources */, F4578D592A2659C5005B311A /* LiveStreamOverlay.swift in Sources */, + F486B3092C0E69E60066749F /* MediaDownloadManager.swift in Sources */, 9104BDFE2A25165A00860C08 /* Combine+UI.swift in Sources */, DDFA10BF1EBEAAAD001DCF66 /* DownloadManager.swift in Sources */, DD0159A71ECFE26200F980F1 /* DeepLink.swift in Sources */, @@ -1445,6 +1510,7 @@ DDA60E1520A907B6002EECF5 /* SessionCollectionViewItem.swift in Sources */, DD7F38881EAC2275002D8C00 /* PathUtil.swift in Sources */, DDB352821EC7C55300254815 /* DateProvider.swift in Sources */, + F486B3082C0E69E60066749F /* FSMediaDownloadMetadataStore.swift in Sources */, DDC678221EDB956700A4E19C /* BookmarkViewController.swift in Sources */, DD7F386A1EABE996002D8C00 /* SessionTableCellView.swift in Sources */, 4DF6641620C8A85000FD1684 /* SessionsTableViewController+SupportingTypesAndExtensions.swift in Sources */, @@ -1455,9 +1521,11 @@ DD7F385F1EABD631002D8C00 /* WWDCTabViewController.swift in Sources */, F474DEC926737EFA00B28B31 /* SharePlayManager.swift in Sources */, 4D66CA50217E2C800006A8C9 /* DownloadsManagementTableRowView.swift in Sources */, + F486B3072C0E69E60066749F /* MediaDownloadProtocols.swift in Sources */, DDCE7ED91EA7A86600C7A3CA /* AppCoordinator.swift in Sources */, DD0159D11ED0CEF500F980F1 /* PreferencesCoordinator.swift in Sources */, DDEDFCF31ED92F2A002477C8 /* TextualFilter.swift in Sources */, + F486B3102C0E69E60066749F /* String+Error.swift in Sources */, DD0159A91ED09F5D00F980F1 /* AppCoordinator+Bookmarks.swift in Sources */, DD6E06F41EDBC11F000EAEA4 /* WWDCTextButton.swift in Sources */, DD7F38621EABD6CF002D8C00 /* SessionsTableViewController.swift in Sources */, @@ -1470,6 +1538,7 @@ 4DBA2F7620FE71BF00ED0253 /* DownloadsStatusButton.swift in Sources */, DD6E06F81EDBC62D000EAEA4 /* SessionTranscriptViewController.swift in Sources */, DD36A4B01E478C6A00B2EA88 /* AppDelegate.swift in Sources */, + F486B30F2C0E69E60066749F /* PreviewAndTestingSupport.swift in Sources */, DDA7B7182482EB8900F86668 /* PlaybackPreferencesViewController.swift in Sources */, DDF5A5092487066200135E70 /* ClipComposition.swift in Sources */, DD6E06FC1EDBCA7E000EAEA4 /* TranscriptTableCellView.swift in Sources */, @@ -1486,6 +1555,7 @@ DD90CDCD1ED7A5ED00CADE86 /* SearchCoordinator.swift in Sources */, DDB28F6F1EACFCDB0077703F /* VibrantButton.swift in Sources */, 914367202A4C6B0E004E4392 /* Sequence+GroupedBy.swift in Sources */, + F486B30D2C0E69E60066749F /* Bundle+URLSessionID.swift in Sources */, 4D7482CA20FF735D008D156C /* WWDCWindowController.swift in Sources */, 4DA25DCC21064B4800762BBD /* WWDCTabViewControllerTabBar.swift in Sources */, DDEA85FC1EB52AB5002AE0EB /* VideoPlayerWindowController.swift in Sources */, @@ -1510,12 +1580,14 @@ DDF32EB71EBE65930028E39D /* AppCoordinator+UserActivity.swift in Sources */, DD4873D320AE5FF3005033CE /* AppCoordinator+RelatedSessions.swift in Sources */, DDEDFCEF1ED92785002477C8 /* MultipleChoiceFilter.swift in Sources */, + F486B3112C0E69E60066749F /* URL+FileHelpers.swift in Sources */, DDEA85FB1EB52AB5002AE0EB /* VideoPlayerViewController.swift in Sources */, DDA50E3524AA5090007C77C6 /* Boot.swift in Sources */, DDC6781B1EDB629C00A4E19C /* GeneralPreferencesViewController.swift in Sources */, DD4648471ECA5947005C57C6 /* WWDCWindow.swift in Sources */, F4D0F03A2A21056900C74B50 /* TopicHeaderRow.swift in Sources */, DDA7B7162482BB1A00F86668 /* SessionTranscriptWindowController.swift in Sources */, + F486B3132C0E69E60066749F /* MediaDownload.swift in Sources */, DDB28F911EAD2A050077703F /* WWDCAlert.swift in Sources */, DD90CDC81ED77A3900CADE86 /* SearchFiltersViewController.swift in Sources */, DDF32EBB1EBE65DD0028E39D /* AppCoordinator+SessionActions.swift in Sources */, diff --git a/WWDC/MediaDownload/Engines/AVAssetMediaDownloadEngine.swift b/WWDC/MediaDownload/Engines/AVAssetMediaDownloadEngine.swift new file mode 100644 index 00000000..e5b55621 --- /dev/null +++ b/WWDC/MediaDownload/Engines/AVAssetMediaDownloadEngine.swift @@ -0,0 +1,258 @@ +import Cocoa +import AVFoundation +import OSLog +import ConfCore + +public final class AVAssetMediaDownloadEngine: NSObject, MediaDownloadEngine, Logging { + public static var log = makeLogger() + + public let supportedExtensions: Set = ["movpkg"] + + public var manager: MediaDownloadManager + + public init(manager: MediaDownloadManager) { + self.manager = manager + } + + private lazy var configuration = URLSessionConfiguration.background( + withIdentifier: Bundle.main.backgroundURLSessionIdentifier(suffix: "AVAssetMediaDownloadEngine") + ) + + private lazy var session = AVAssetDownloadURLSession(configuration: configuration, assetDownloadDelegate: self, delegateQueue: .main) + + /// Download ID to download task. + private let tasks = NSCache() + + public func pendingDownloadTasks() async -> [MediaDownloadTask] { + let retrievedTasks = await session.allTasks + + let validTasks = retrievedTasks.filter { task in + guard task.taskDescription != nil else { + log.warning("Dropping task without description: \(task, privacy: .public)") + return false + } + return true + } + let invalidTasks = retrievedTasks.filter { !validTasks.contains($0) } + for task in invalidTasks { + task.cancel() + } + + for retrievedTask in validTasks { + do { + let id = try retrievedTask.mediaDownloadID() + + tasks.setObject(retrievedTask, forKey: id as NSString) + } catch { + log.fault("Download task is missing download ID: \(retrievedTask, privacy: .public)") + } + } + + return validTasks + } + + public func fetchTask(for id: MediaDownload.ID) async -> MediaDownloadTask? { + await session.allTasks.first(where: { $0.taskDescription == id }) + } + + private func existingTask(for downloadID: MediaDownload.ID) throws -> URLSessionTask { + guard let task = tasks.object(forKey: downloadID as NSString) else { + throw "Task not found for \(downloadID)" + } + return task + } + + public func start(_ download: MediaDownload) async throws { + let id = download.id + + log.debug("Start \(id, privacy: .public)") + + if let task = try? existingTask(for: id) { + log.info("Found existing task for \(id, privacy: .public), resuming") + task.resume() + return + } + + log.info("Creating new task for \(id, privacy: .public)") + + let asset = AVURLAsset(url: download.remoteURL) + + let mediaSelection = try await asset.load(.preferredMediaSelection) + + guard let task = session.aggregateAssetDownloadTask(with: asset, + mediaSelections: [mediaSelection], + assetTitle: download.title, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000]) + else { + throw "Failed to create aggregate download task for \(download.remoteURL)." + } + + task.setMediaDownloadID(id) + + tasks.setObject(task, forKey: id as NSString) + + task.resume() + } + + public func resume(_ download: MediaDownload) throws { + let task = try existingTask(for: download.id) + + assertSetState(.waiting, for: task) + + task.resume() + } + + /// Tasks that are currently in the process if being suspended. + /// Used to ignore progress callbacks, avoiding race conditions. + private var tasksBeingSuspended = Set() + + public func pause(_ download: MediaDownload) throws { + let task = try existingTask(for: download.id) + + tasksBeingSuspended.insert(task) + + task.suspend() + + assertSetState(download.state.paused(), for: task) + + DispatchQueue.main.async { + self.tasksBeingSuspended.remove(task) + } + } + + public func cancel(_ download: MediaDownload) throws { + try existingTask(for: download.id).cancel() + } + + public func cancel(_ task: MediaDownloadTask) throws { + guard let typedTask = task as? AVAggregateAssetDownloadTask else { + throw "Invalid task type: \(task)." + } + + guard let id = typedTask.taskDescription else { + throw "Task is missing download ID: \(typedTask)." + } + + tasks.removeObject(forKey: id as NSString) + + typedTask.cancel() + } + +} + +extension AVAssetMediaDownloadEngine: AVAssetDownloadDelegate { + + private func handleTaskFinished(_ task: AVAssetDownloadTask, location: URL?) { + let id = task.debugDownloadID + + log.info("Finished downloading for \(id, privacy: .public)") + + do { + let newTempLocation: URL? + + if let location { + /// The temporary file provided by URLSession only exists until we return from this method, so move it into another place. + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(location.lastPathComponent) + + try FileManager.default.moveItem(at: location, to: tempURL) + + log.debug("Moved temporary download for \(id, privacy: .public) into \(tempURL.path)") + + newTempLocation = tempURL + } else { + newTempLocation = nil + } + + assertSetState(.completed, for: task, location: newTempLocation) + } catch { + log.fault("Failed to move downloaded file for \(id, privacy: .public) into temporary location: \(error, privacy: .public)") + } + } + + private func reportProgress(for task: MediaDownloadTask, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { + var percentComplete = 0.0 + for value in loadedTimeRanges { + let loadedTimeRange: CMTimeRange = value.timeRangeValue + percentComplete += + loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds + } + + assertSetState(.downloading(progress: percentComplete), for: task) + } + + public func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + log.debug("\(#function)") + + handleTaskFinished(assetDownloadTask, location: location) + } + + public func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, willDownloadTo location: URL) { + log.debug("\(#function)") + + let id = aggregateAssetDownloadTask.debugDownloadID + + log.debug("Will download \(id, privacy: .public) to \(location.path)") + + assertSetState(for: aggregateAssetDownloadTask, location: location) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + let id = task.debugDownloadID + + defer { + tasks.removeObject(forKey: id as NSString) + } + + guard let error else { + log.debug("Task completed: \(task)") + + /// Location for this type of task is set by the `willDownloadTo` callback, so here we can just report completion. + assertSetState(.completed, for: task) + + return + } + + if error.isURLSessionCancellation { + log.warning("Task for \(id, privacy: .public) cancelled") + + /// We may get a cancellation callback after a task is cancelled due to restoration failing, + /// in which case it'll be removed from our task cache before the callback occurs. + /// When that's the case, we can ignore the callback. + guard tasks.object(forKey: id as NSString) != nil else { + log.warning("Ignoring cancellation callback for removed task \(id, privacy: .public)") + return + } + + assertSetState(.cancelled, for: task) + } else { + log.warning("Task for \(id, privacy: .public) completed with error: \(error, privacy: .public)") + + assertSetState(.failed(message: error.localizedDescription), for: task) + } + } + + public func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didCompleteFor mediaSelection: AVMediaSelection) { + log.debug("\(#function)") + + /// This is super weird, but it's what Apple's sample code does :| + aggregateAssetDownloadTask.resume() + } + + public func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { + log.debug("\(#function)") + + reportProgress(for: assetDownloadTask, totalTimeRangesLoaded: loadedTimeRanges, timeRangeExpectedToLoad: timeRangeExpectedToLoad) + } + + public func urlSession(_ session: URLSession, aggregateAssetDownloadTask: AVAggregateAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange, for mediaSelection: AVMediaSelection) { + guard !tasksBeingSuspended.contains(aggregateAssetDownloadTask) else { + let id = aggregateAssetDownloadTask.debugDownloadID + log.debug("Ignoring progress report for \(id, privacy: .public) because it's being suspended") + return + } + + reportProgress(for: aggregateAssetDownloadTask, totalTimeRangesLoaded: loadedTimeRanges, timeRangeExpectedToLoad: timeRangeExpectedToLoad) + } +} diff --git a/WWDC/MediaDownload/Engines/SimulatedMediaDownloadEngine.swift b/WWDC/MediaDownload/Engines/SimulatedMediaDownloadEngine.swift new file mode 100644 index 00000000..eee78832 --- /dev/null +++ b/WWDC/MediaDownload/Engines/SimulatedMediaDownloadEngine.swift @@ -0,0 +1,255 @@ +import Foundation + +/// A fake downloader that can be used for unit/UI testing. +public final class SimulatedMediaDownloadEngine: MediaDownloadEngine { + + /// If a `MediaDownload` started through the fake downloader has this identifier, then it will fail instead of succeed. + public static let simulateFailureMediaDownloadID = "FAILTHIS" + + public var supportedExtensions: Set = [] + + public func supports(_ download: MediaDownload) -> Bool { true } + + public var simulatedInFlightDownloads = [MediaDownload]() + + public var manager: MediaDownloadManager + + public init(manager: MediaDownloadManager) { + self.manager = manager + } + + public func createSimulatedPendingTask(with id: MediaDownload.ID, state: MediaDownloadState) { + guard self.tasksByDownloadID[id] == nil else { return } + let task = SimulatedDownloadTask(downloadID: id, delegate: self, initialState: state) + self.tasksByDownloadID[id] = task + task.resume() + } + + public func pendingDownloadTasks() async -> [MediaDownloadTask] { + if let simulatePendingTaskIDs = UserDefaults.standard.string(forKey: "SimulatedDownloadEnginePendingTaskIDs").flatMap({ $0.components(separatedBy: ",").map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) }) { + for taskID in simulatePendingTaskIDs { + createSimulatedPendingTask(with: taskID, state: .waiting) + } + } + return Array(tasksByDownloadID.values) + } + + public func fetchTask(for id: MediaDownload.ID) async -> MediaDownloadTask? { + await pendingDownloadTasks().first(where: { (try? $0.mediaDownloadID()) == id }) + } + + private var tasksByDownloadID = [MediaDownload.ID: SimulatedDownloadTask]() + + private func task(for downloadID: MediaDownload.ID) throws -> SimulatedDownloadTask { + guard let task = tasksByDownloadID[downloadID] else { + throw "Couldn't find a task for \(downloadID)" + } + return task + } + + public func start(_ download: MediaDownload) async throws { + let task = tasksByDownloadID[download.id, default: SimulatedDownloadTask(downloadID: download.id, delegate: self)] + task.resume() + } + + public func resume(_ download: MediaDownload) throws { + guard let task = tasksByDownloadID[download.id] else { + throw "Download not found for \(download.id)." + } + + assertSetState(.waiting, for: task) + + task.resume() + } + + public func pause(_ download: MediaDownload) throws { + try task(for: download.id).pause() + } + + public func cancel(_ download: MediaDownload) throws { + try task(for: download.id).cancel() + } + + public func cancel(_ task: MediaDownloadTask) throws { + guard let typedTask = task as? SimulatedDownloadTask else { + throw "Invalid task: \(task)." + } + + typedTask.cancel() + + if let taskKey = tasksByDownloadID.first(where: { $0.value === typedTask })?.key { + tasksByDownloadID[taskKey] = nil + } + } + +} + +extension SimulatedMediaDownloadEngine: SimulatedDownloadTaskDelegate { + func simulatedDownloadTaskResumed(_ task: SimulatedDownloadTask) { + assertSetState(task.progress > 0 ? .downloading(progress: task.progress) : .waiting, for: task) + } + + func simulatedDownloadTaskPaused(_ task: SimulatedDownloadTask) { + assertSetState(.paused(progress: task.progress), for: task) + } + + func simulatedDownloadTaskFailed(_ task: SimulatedDownloadTask, error: any Error) { + assertSetState(.failed(message: String(describing: error)), for: task) + } + + func simulatedDownloadTaskCancelled(_ task: SimulatedDownloadTask) { + assertSetState(.cancelled, for: task) + } + + func simulatedDownloadTaskProgressChanged(_ task: SimulatedDownloadTask, progress: Double) { + assertSetState(.downloading(progress: progress), for: task) + } + + func simulatedDownloadTaskCompleted(_ task: SimulatedDownloadTask, location: URL) { + assertSetState(.completed, for: task, location: location) + } +} + +protocol SimulatedDownloadTaskDelegate: AnyObject { + func simulatedDownloadTaskResumed(_ task: SimulatedDownloadTask) + func simulatedDownloadTaskPaused(_ task: SimulatedDownloadTask) + func simulatedDownloadTaskFailed(_ task: SimulatedDownloadTask, error: Error) + func simulatedDownloadTaskCancelled(_ task: SimulatedDownloadTask) + func simulatedDownloadTaskProgressChanged(_ task: SimulatedDownloadTask, progress: Double) + func simulatedDownloadTaskCompleted(_ task: SimulatedDownloadTask, location: URL) +} + +final class SimulatedDownloadTask: MediaDownloadTask { + var downloadID: MediaDownload.ID + weak var delegate: SimulatedDownloadTaskDelegate? + private(set) var progress: Double = 0 + + init(downloadID: MediaDownload.ID, delegate: SimulatedDownloadTaskDelegate, initialState: MediaDownloadState = .waiting) { + self.downloadID = downloadID + self.delegate = delegate + self.internalState = initialState + } + + func mediaDownloadID() throws -> MediaDownload.ID { + downloadID + } + + func setMediaDownloadID(_ id: MediaDownload.ID) { + self.downloadID = id + } + + @SimulatedTaskActor + private var internalState = MediaDownloadState.waiting + + private var internalProgressTask: Task? + + @SimulatedTaskActor + private func updateInternalState(_ state: MediaDownloadState, location: URL? = nil) async { + /// After reaching a final state, the download state can't be changed. + guard !internalState.isFinal else { + return + } + + internalState = state + + await MainActor.run { + switch state { + case .waiting: + break + case .downloading(let progress): + self.delegate?.simulatedDownloadTaskProgressChanged(self, progress: progress) + case .paused: + self.delegate?.simulatedDownloadTaskPaused(self) + case .failed(let message): + self.delegate?.simulatedDownloadTaskFailed(self, error: message) + case .completed: + guard let location else { + self.delegate?.simulatedDownloadTaskFailed(self, error: "Missing file location for completed task.") + return + } + self.delegate?.simulatedDownloadTaskCompleted(self, location: location) + case .cancelled: + self.delegate?.simulatedDownloadTaskCancelled(self) + } + } + } + + func resume() { + Task { + try? await Task.sleep(nanoseconds: 200 * NSEC_PER_MSEC) + + await updateInternalState(.downloading(progress: self.progress)) + + runProgressTask() + } + } + + func pause() { + internalProgressTask?.cancel() + internalProgressTask = nil + + Task { + await updateInternalState(.paused(progress: progress)) + } + } + + func cancel() { + Task { + await updateInternalState(.cancelled) + } + } + + private func runProgressTask() { + internalProgressTask = Task { + await progressTaskMain() + } + } + + private func progressTaskMain() async { + do { + while !(await internalState.isFinal) { + await Task.yield() + + try Task.checkCancellation() + + try? await Task.sleep(nanoseconds: 100 * NSEC_PER_MSEC) + + let p = min(self.progress + 0.02, 1.0) + + self.progress = p + + await updateInternalState(.downloading(progress: p)) + + try Task.checkCancellation() + + let state = await internalState + + guard state != .cancelled else { break } + + if p >= 0.2, self.downloadID == SimulatedMediaDownloadEngine.simulateFailureMediaDownloadID { + await updateInternalState(.failed(message: "Simulated error.")) + break + } + + if p >= 1 { + let simulatedFileURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("SimulatedDownload-\(UUID()).tmp") + + try Data("Simulated Download".utf8).write(to: simulatedFileURL) + + await updateInternalState(.completed, location: simulatedFileURL) + } + } + } catch is CancellationError { + return + } catch { + await updateInternalState(.failed(message: error.localizedDescription)) + } + } + +} + +@globalActor +private final actor SimulatedTaskActor: GlobalActor { + static let shared = SimulatedTaskActor() +} diff --git a/WWDC/MediaDownload/Engines/URLSessionMediaDownloadEngine.swift b/WWDC/MediaDownload/Engines/URLSessionMediaDownloadEngine.swift new file mode 100644 index 00000000..ca053981 --- /dev/null +++ b/WWDC/MediaDownload/Engines/URLSessionMediaDownloadEngine.swift @@ -0,0 +1,202 @@ +import Cocoa +import OSLog +import ConfCore + +public final class URLSessionMediaDownloadEngine: NSObject, MediaDownloadEngine, Logging { + public static var log = makeLogger() + + public let supportedExtensions: Set = ["mp4", "mov", "m4v"] + + public var manager: MediaDownloadManager + + public init(manager: MediaDownloadManager) { + self.manager = manager + } + + private lazy var configuration = URLSessionConfiguration.background( + withIdentifier: Bundle.main.backgroundURLSessionIdentifier(suffix: "URLSessionMediaDownloadEngine") + ) + + private lazy var session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) + + /// Download ID to download task. + private let tasks = NSCache() + + public func pendingDownloadTasks() async -> [MediaDownloadTask] { + let retrievedTasks = await session.allTasks + + let validTasks = retrievedTasks.filter { task in + guard task.taskDescription != nil else { + log.warning("Dropping task without description: \(task, privacy: .public)") + return false + } + return true + } + let invalidTasks = retrievedTasks.filter { !validTasks.contains($0) } + for task in invalidTasks { + task.cancel() + } + + for retrievedTask in validTasks { + do { + let id = try retrievedTask.mediaDownloadID() + + tasks.setObject(retrievedTask, forKey: id as NSString) + } catch { + log.fault("Download task is missing download ID: \(retrievedTask, privacy: .public)") + } + } + + return validTasks + } + + public func fetchTask(for id: MediaDownload.ID) async -> MediaDownloadTask? { + await session.allTasks.first(where: { $0.taskDescription == id }) + } + + private func existingTask(for downloadID: MediaDownload.ID) throws -> URLSessionTask { + guard let task = tasks.object(forKey: downloadID as NSString) else { + throw "Task not found for \(downloadID)" + } + return task + } + + public func start(_ download: MediaDownload) async throws { + let id = download.id + + log.debug("Start \(id, privacy: .public)") + + if let task = try? existingTask(for: id) { + log.info("Found existing task for \(id, privacy: .public), resuming") + task.resume() + return + } + + log.info("Creating new task for \(id, privacy: .public)") + + let request = URLRequest(url: download.remoteURL) + let downloadTask = session.downloadTask(with: request) + downloadTask.setMediaDownloadID(id) + + tasks.setObject(downloadTask, forKey: id as NSString) + + downloadTask.resume() + } + + public func resume(_ download: MediaDownload) throws { + let task = try existingTask(for: download.id) + + assertSetState(.waiting, for: task) + + task.resume() + } + + /// Tasks that are currently in the process if being suspended. + /// Used to ignore progress callbacks, avoiding race conditions. + private var tasksBeingSuspended = Set() + + public func pause(_ download: MediaDownload) throws { + let task = try existingTask(for: download.id) + + tasksBeingSuspended.insert(task) + + task.suspend() + + assertSetState(download.state.paused(), for: task) + + DispatchQueue.main.async { + self.tasksBeingSuspended.remove(task) + } + } + + public func cancel(_ download: MediaDownload) throws { + try existingTask(for: download.id).cancel() + } + + public func cancel(_ task: MediaDownloadTask) throws { + guard let typedTask = task as? URLSessionTask else { + throw "Invalid task type: \(task)." + } + + guard let id = typedTask.taskDescription else { + throw "Task is missing download ID: \(typedTask)." + } + + tasks.removeObject(forKey: id as NSString) + + typedTask.cancel() + } + +} + +extension URLSessionMediaDownloadEngine: URLSessionDownloadDelegate, URLSessionTaskDelegate { + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + let id = downloadTask.debugDownloadID + + log.info("Finished downloading for \(id, privacy: .public)") + + do { + /// The temporary file provided by URLSession only exists until we return from this method, so move it into another place. + let newTempLocation = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(location.lastPathComponent) + + try FileManager.default.moveItem(at: location, to: newTempLocation) + + log.debug("Moved temporary download for \(id, privacy: .public) into \(newTempLocation.path)") + + assertSetState(.completed, for: downloadTask, location: newTempLocation) + } catch { + log.fault("Failed to move downloaded file for \(id, privacy: .public) into temporary location: \(error, privacy: .public)") + } + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let id = task.debugDownloadID + + defer { + tasks.removeObject(forKey: id as NSString) + } + + guard let error else { + log.debug("Task completed: \(task)") + return + } + + if error.isURLSessionCancellation { + log.warning("Task for \(id, privacy: .public) cancelled") + + /// We may get a cancellation callback after a task is cancelled due to restoration failing, + /// in which case it'll be removed from our task cache before the callback occurs. + /// When that's the case, we can ignore the callback. + guard tasks.object(forKey: id as NSString) != nil else { + log.warning("Ignoring cancellation callback for removed task \(id, privacy: .public)") + return + } + + assertSetState(.cancelled, for: task) + } else { + log.warning("Task for \(id, privacy: .public) completed with error: \(error, privacy: .public)") + + assertSetState(.failed(message: error.localizedDescription), for: task) + } + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + guard !tasksBeingSuspended.contains(downloadTask) else { + let id = downloadTask.debugDownloadID + log.debug("Ignoring progress report for \(id, privacy: .public) because it's being suspended") + return + } + + let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) + + assertSetState(.downloading(progress: progress), for: downloadTask) + } +} + +extension Error { + var isURLSessionCancellation: Bool { + let nsError = self as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == -999 + } +} diff --git a/WWDC/MediaDownload/FSMediaDownloadMetadataStore.swift b/WWDC/MediaDownload/FSMediaDownloadMetadataStore.swift new file mode 100644 index 00000000..f907989e --- /dev/null +++ b/WWDC/MediaDownload/FSMediaDownloadMetadataStore.swift @@ -0,0 +1,99 @@ +import Cocoa +import OSLog +import ConfCore + +public final class FSMediaDownloadMetadataStore: MediaDownloadMetadataStorage, Logging { + public static var log = makeLogger() + + public let directoryURL: URL + private let fileManager = FileManager() + private let cache = NSCache() + + public init(directoryURL: URL) { + self.directoryURL = directoryURL + } + + public func persistedIdentifiers() throws -> Set { + guard directoryURL.exists else { return [] } + + let contents = try fileManager.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants, .skipsPackageDescendants]) + + return Set( + contents + .filter { $0.pathExtension == "plist" } + .map { $0.deletingPathExtension().lastPathComponent } + ) + } + + public func fetch(_ id: MediaDownload.ID) throws -> MediaDownload { + if let cached = cache.object(forKey: id as NSString) { + return cached + } + + do { + let url = try fileURL(for: id) + + guard url.exists else { throw "Metadata not found for \(id)." } + + let data = try Data(contentsOf: url) + + let download = try PropertyListDecoder.metaStore.decode(MediaDownload.self, from: data) + + cache.setObject(download, forKey: id as NSString) + + return download + } catch { + log.error("Error fetching \(id, privacy: .public): \(error, privacy: .public)") + throw error + } + } + + public func persist(_ download: MediaDownload) throws { + let id = download.id + + cache.setObject(download, forKey: id as NSString) + + do { + let data = try PropertyListEncoder.metaStore.encode(download) + + let url = try fileURL(for: id) + + try data.write(to: url, options: .atomic) + } catch { + log.error("Error persisting \(id, privacy: .public): \(error, privacy: .public)") + throw error + } + } + + public func remove(_ id: MediaDownload.ID) throws { + cache.removeObject(forKey: id as NSString) + + guard directoryURL.exists else { + log.fault("Asked to remove download \(id, privacy: .public), but metadata directory doesn't exist at \(self.directoryURL.path, privacy: .public)") + return + } + + do { + try fileManager.removeItem(at: fileURL(for: id)) + } catch { + log.error("Error deleting metadata for \(id, privacy: .public): \(error, privacy: .public)") + throw error + } + } +} + +private extension FSMediaDownloadMetadataStore { + func fileURL(for id: MediaDownload.ID) throws -> URL { + try directoryURL.creatingIfNeeded() + .appendingPathComponent(id) + .appendingPathExtension("plist") + } +} + +private extension PropertyListEncoder { + static let metaStore = PropertyListEncoder() +} + +private extension PropertyListDecoder { + static let metaStore = PropertyListDecoder() +} diff --git a/WWDC/MediaDownload/MediaDownload.swift b/WWDC/MediaDownload/MediaDownload.swift new file mode 100644 index 00000000..bdb49747 --- /dev/null +++ b/WWDC/MediaDownload/MediaDownload.swift @@ -0,0 +1,245 @@ +import Cocoa +import Combine + +@frozen +public enum MediaDownloadState: Codable, Hashable { + case waiting + case downloading(progress: Double) + case paused(progress: Double) + case failed(message: String) + case completed + case cancelled +} + +/// Represents a media download, encapsulating its state. +public final class MediaDownload: Identifiable, ObservableObject, Hashable, Codable { + /// Internal representation used for Codable conformance. + fileprivate struct Storage: Codable { + var id: String + var relativeLocalPath: String + var creationDate: Date + var title: String + var remoteURL: URL + var temporaryLocalFileURL: URL? + var state: MediaDownloadState + } + + public struct ProgressStats: Hashable { + fileprivate static let minElapsedProgressForETA: Double = 0.01 + fileprivate static let ppsObservationsLimit = 500 + private var elapsedTime: Double = 0 + private var ppsObservations: [Double] = [] + private var pps: Double = -1 + private var lastProgressDate = Date() + private var ppsAverage: Double { + guard !ppsObservations.isEmpty else { return -1 } + return ppsObservations.reduce(Double(0), +) / Double(ppsObservations.count) + } + fileprivate var progress: Double = -1 + + public var eta: Double? { + didSet { + formattedETA = eta.flatMap { Self.formattedETA(from: $0) } + } + } + + public var formattedETA: String? + + init() { } + } + + /// When the download is in progress, reports statistics about the download (such as the estimated time for completion). + @Published public private(set) var stats: ProgressStats? + + private var storage: Storage + + /// The unique identifier for the content being downloaded. + public private(set) var id: String { + get { storage.id } + set { storage.id = newValue } + } + + /// Local path to where the file will be saved after downloading, relative to the downloads directory. + public private(set) var relativeLocalPath: String { + get { storage.relativeLocalPath } + set { storage.relativeLocalPath = newValue } + } + + /// Date when this download was first created. + public private(set) var creationDate: Date { + get { storage.creationDate } + set { storage.creationDate = newValue } + } + + /// User-facing title representing the content. + public private(set) var title: String { + get { storage.title } + set { storage.title = newValue } + } + + /// URL to the remote content being downloaded. + public private(set) var remoteURL: URL { + get { storage.remoteURL } + set { storage.remoteURL = newValue } + } + + /// URL to the temporary location where the system will download the media into. + /// After the download completes, the file is moved into its final location. + public internal(set) var temporaryLocalFileURL: URL? { + get { storage.temporaryLocalFileURL } + set { storage.temporaryLocalFileURL = newValue } + } + + /// Observers of the download's state may use this so that subscriptions + /// die automatically when the `MediaDownload` object is discarded. + var cancellables = Set() + + /// The current state of the download. + @Published public internal(set) var state: MediaDownloadState { + didSet { + storage.state = state + updateStatsIfNeeded() + } + } + + init(id: String, title: String, remoteURL: URL, relativeLocalPath: String, state: MediaDownloadState = .waiting, creationDate: Date = .now) { + self.storage = Storage(id: id, relativeLocalPath: relativeLocalPath, creationDate: creationDate, title: title, remoteURL: remoteURL, temporaryLocalFileURL: nil, state: state) + self.state = state + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: MediaDownload, rhs: MediaDownload) -> Bool { lhs.id == rhs.id } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + self.storage = try container.decode(Storage.self) + self.state = storage.state + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(storage) + } +} + +public extension MediaDownloadState { + var isFinal: Bool { + switch self { + case .failed, .completed, .cancelled: + return true + default: + return false + } + } + + var isResumable: Bool { + switch self { + case .paused, .failed, .cancelled: + return true + default: + return false + } + } + + var isCompleted: Bool { self == .completed } + + var isPaused: Bool { + guard case .paused = self else { return false } + return true + } + + var isFailed: Bool { + guard case .failed = self else { return false } + return true + } + + var isCancelled: Bool { self == .cancelled } + + var progress: Double? { + switch self { + case .downloading(let progress), .paused(let progress): return progress + default: return nil + } + } + + /// Convenience for transitioning into paused state with current progress (if any). + func paused() -> Self { + switch self { + case .downloading(let progress), .paused(let progress): + return .paused(progress: progress) + default: + return .paused(progress: 0) + } + } +} + +public extension MediaDownload { + var isResumable: Bool { state.isResumable } + var isPaused: Bool { state.isPaused } + var isFailed: Bool { state.isFailed } + var isCompleted: Bool { state.isCompleted } + var isCancelled: Bool { state.isCancelled } + var progress: Double? { state.progress } + + /// Whether the download can be manually removed from the list by the user. + var isRemovable: Bool { isCompleted || isCancelled || isFailed } + /// Whether the user can request that the download be attempted again. + var isRetryable: Bool { isCancelled || isFailed } +} + +// MARK: - ETA Support + +private extension MediaDownload { + func updateStatsIfNeeded() { + guard case .downloading(let progress) = state else { return } + + var stats = self.stats ?? ProgressStats() + stats.update(with: progress) + self.stats = stats + } +} + +private extension MediaDownload.ProgressStats { + mutating func update(with progress: Double) { + let interval = Date().timeIntervalSince(lastProgressDate) + lastProgressDate = Date() + + let currentPPS = progress / elapsedTime + + if currentPPS.isFinite && !currentPPS.isZero && !currentPPS.isNaN { + ppsObservations.append(currentPPS) + if ppsObservations.count >= Self.ppsObservationsLimit { + ppsObservations.removeFirst() + } + } + + elapsedTime += interval + + if self.progress > Self.minElapsedProgressForETA { + if pps < 0 { + pps = progress / elapsedTime + } + + eta = (1/ppsAverage) - elapsedTime + } + + self.progress = progress + } + + static func formattedETA(from eta: Double) -> String { + let time = Int(eta) + + let seconds = time % 60 + let minutes = (time / 60) % 60 + let hours = (time / 3600) + + if hours >= 1 { + return String(format: "%0.2d:%0.2d:%0.2d", hours, minutes, seconds) + } else { + return String(format: "%0.2d:%0.2d", minutes, seconds) + } + } +} diff --git a/WWDC/MediaDownload/MediaDownloadManager.swift b/WWDC/MediaDownload/MediaDownloadManager.swift new file mode 100644 index 00000000..0c98bece --- /dev/null +++ b/WWDC/MediaDownload/MediaDownloadManager.swift @@ -0,0 +1,557 @@ +import Cocoa +import AVFoundation +import OSLog +import Combine +import ConfCore + +public final class MediaDownloadManager: ObservableObject, Logging { + + public static let log = makeLogger() + + public typealias Downloadable = DownloadableMediaContainer + + @MainActor + @Published public private(set) var downloads = [MediaDownload]() + + /// Internal use only, propagates to published `downloads` property. + private var mediaDownloads = Set() { + didSet { + DispatchQueue.main.async { + self.downloads = self.mediaDownloads.sorted(by: { $0.creationDate < $1.creationDate }) + } + } + } + + /// Directory where downloaded content is stored. + public var directoryURL: URL + + private let fileManager = FileManager() + private let engineTypes: [MediaDownloadEngine.Type] + private var engines = [MediaDownloadEngine]() + private let metaStore: MediaDownloadMetadataStorage + + public init(directoryURL: URL, + engines engineTypes: [MediaDownloadEngine.Type], + metadataStorage metaStore: MediaDownloadMetadataStorage) + { + self.directoryURL = directoryURL + self.engineTypes = engineTypes + self.metaStore = metaStore + } + + private var activated = false + + @MainActor + public func activate() { + guard !activated else { return } + activated = true + + assert(!engineTypes.isEmpty, "\(String(describing: Self.self)) requires at least one engine") + + log.debug("Activating with \(self.engineTypes.count, privacy: .public) engine(s)") + + self.engines = engineTypes.map { $0.init(manager: self) } + + Task { + await _restorePendingDownloads() + await _purgeOrphanedDownloads() + } + } + + /// Starts downloading media for the specified content. + /// Variants are in preferred order, the first variant that's available will be used. + @discardableResult + public func startDownload(for content: T, variants: [T.MediaVariant] = T.mediaDownloadVariants) async throws -> MediaDownload { + guard downloadedFileURL(for: content, variants: variants) == nil else { + throw "Content has already been downloaded, remove existing download before attempting to download again." + } + + do { + for variant in variants { + if let url = content.remoteDownloadURL(for: variant) { + guard let localPath = content.relativeLocalPath(for: variant) else { + throw "Unable to determine local path for downloading \(content.id), variant \(variant)." + } + + return try await _startDownload(for: content, remoteURL: url, relativeLocalPath: localPath) + } + } + + throw "Couldn't find a downloadable variant for \(content.id)" + } catch { + log.error("Start failed for \(content.id, privacy: .public): \(error, privacy: .public)") + + throw error + } + } + + /// Fetch the existing local file URL for the specified content. + /// - Parameters: + /// - content: The content to get the local file URL for. + /// - variants: Preferred variants, sorted by most to least preferred. + /// - Returns: The existing local file URL for the first variant of the specified content. + public func downloadedFileURL(for content: T, variants: [T.MediaVariant] = T.mediaDownloadVariants) -> URL? { + for variant in variants { + guard let fileURL = _localFileURL(for: content, variant: variant) else { continue } + if fileManager.fileExists(atPath: fileURL.path) { return fileURL } + } + return nil + } + + /// Checks if a given content has been downloaded. + /// - Parameters: + /// - content: The content to check. + /// - variants: Preferred variants, sorted by most to least preferred. + /// - Returns: `true` if a local download exists for any of the specified variants. + public func hasDownloadedMedia(for content: T, variants: [T.MediaVariant] = T.mediaDownloadVariants) -> Bool { + downloadedFileURL(for: content, variants: variants) != nil + } + + /// Deletes existing downloaded media for the specified content / variants. + public func removeDownloadedMedia(for content: T, variants: [T.MediaVariant] = T.mediaDownloadVariants) throws { + guard let fileURL = downloadedFileURL(for: content, variants: variants) else { + throw "Download doesn't exist for \(content.id)." + } + try fileManager.removeItem(at: fileURL) + } + + /// Returns the active download for the specified content, if any. + public func download(for content: T) -> MediaDownload? { + try? _download(with: content.id) + } + + /// Checks if there's an active download for the specified content. + /// Returns `true` for any download state except for `.completed`. + public func isDownloadingMedia(for content: T) -> Bool { + guard let download = (try? _download(with: content.id)) else { return false } + return download.state != .completed + } + + public func pause(_ download: MediaDownload) throws { + let engine = try _engine(for: download) + try engine.pause(download) + } + + public func resume(_ download: MediaDownload) throws { + let engine = try _engine(for: download) + try engine.resume(download) + } + + public func cancel(_ download: MediaDownload) throws { + let engine = try _engine(for: download) + try engine.cancel(download) + + try? _detach(download, persist: true, remove: true) + } + + /// Removes all completed downloads from the list. + public func clearCompleted() { + let completedDownloads = mediaDownloads.filter(\.isCompleted) + + guard !completedDownloads.isEmpty else { + log.info("Found no completed downloads to remove") + return + } + + log.info("Removing \(completedDownloads.count, privacy: .public) completed download(s) from the list") + + completedDownloads.forEach { mediaDownloads.remove($0) } + } + + /// Removes the specified download from the list, if it's completed. + public func clear(_ download: MediaDownload) { + let id = download.id + + log.debug("Remove download \(id, privacy: .public)") + + guard mediaDownloads.contains(where: { $0.id == id }) else { + log.warning("Couldn't find download \(id, privacy: .public)") + return + } + + guard download.isRemovable else { + log.warning("Can't clear download that's not removable. State: \(download.state, privacy: .public)") + return + } + + mediaDownloads.remove(download) + } + + /// Retries a failed download. + public func retry(_ download: MediaDownload) async throws { + guard download.isFailed else { + throw "Can't retry a download that hasn't failed." + } + + clear(download) + + try await _start(download: download, attach: true) + } + +} + +// MARK: - API for MediaDownloading Implementations + +/// APIs in this extension are meant to be used by implementations of ``MediaDownloading``. +extension MediaDownloadManager { + /// Returns the download corresponding to the specified task. + /// Meant to be called by implementations of ``MediaDownloading``. + func _download(for task: MediaDownloadTask) throws -> MediaDownload { + try _download(with: task.mediaDownloadID()) + } + + /// Returns the download corresponding to the specified download identifier. + /// Meant to be called by implementations of ``MediaDownloading``. + func _download(with id: MediaDownload.ID) throws -> MediaDownload { + guard let download = self.mediaDownloads.first(where: { $0.id == id }) else { + throw "Download not found for \(id)." + } + return download + } + + /// Used internally to restore a pending download upon demand from a download engine lookup. + /// The difference from the `_download(with:)` function above is that this will look up the download metadata + /// from the meta store if needed and re-attach the download to the manager. + /// This is needed because it's possible for an engine to request a download state update before we've finished the initial restoration process. + func _onDemandRestoreDownload(with id: MediaDownload.ID) throws -> MediaDownload { + /// Download is already available locally. + if let download = mediaDownloads.first(where: { $0.id == id }) { return download } + + /// Check if we have stored metadata for the download. + let restoredDownload = try metaStore.fetch(id) + + /// Attach and return the restored download. + return try _attach(restoredDownload, persist: false) + } + + func _persist(_ download: MediaDownload) { + guard download.state != .completed else { return } + + let id = download.id + + do { + try metaStore.persist(download) + } catch { + log.warning("Error persisting \(id, privacy: .public): \(error, privacy: .public)") + } + } +} + +// MARK: MediaDownloadEngine Helpers + +extension MediaDownloadEngine { + /// Updates the state for the download associated with the given task. + func updateState(_ state: MediaDownloadState? = nil, for task: MediaDownloadTask, temporaryLocalFileURL: URL? = nil) throws { + let download = try manager._onDemandRestoreDownload(with: task.mediaDownloadID()) + + let shouldPersist = download.shouldPersist(state) || temporaryLocalFileURL?.path != download.temporaryLocalFileURL?.path + + DispatchQueue.main.async { + if let temporaryLocalFileURL { download.temporaryLocalFileURL = temporaryLocalFileURL } + + if let state { download.state = state } + + if shouldPersist { self.manager._persist(download) } + } + } + + func assertSetState(_ state: MediaDownloadState? = nil, for task: MediaDownloadTask, location: URL? = nil) { + do { + try updateState(state, for: task, temporaryLocalFileURL: location) + } catch { + let downloadID = (try? task.mediaDownloadID()) ?? "" + /// We may encounter a failure when updating to cancelled state, that can safely be ignored. + if state?.isCancelled != true { assertionFailure("State update failed for \(downloadID): \(error)") } + } + } +} + +// MARK: - Private API + +private extension MediaDownloadManager { + + /// Returns the local file URL where the download should be stored, regardless of whether it exists. + func _localFileURL(for content: T, variant: T.MediaVariant) -> URL? { + guard let relativePath = content.relativeLocalPath(for: variant) else { return nil } + return directoryURL.appendingPathComponent(relativePath) + } + + func _engine(for download: MediaDownload) throws -> MediaDownloadEngine { + guard let supportedEngine = engines.first(where: { $0.supports(download) }) else { + throw "Couldn't find a suitable engine for \(download.id) with relative local path \(download.relativeLocalPath)." + } + return supportedEngine + } + + /// Creates and starts a download for the specified content and remote URL. + func _startDownload(for content: T, remoteURL: URL, relativeLocalPath: String) async throws -> MediaDownload { + let id = content.id + + let download: MediaDownload + var isNewDownload = false + + func createDownload() -> MediaDownload { + isNewDownload = true + + return MediaDownload( + id: content.id, + title: content.title, + remoteURL: remoteURL, + relativeLocalPath: relativeLocalPath + ) + } + + if let existingDownload = self.download(for: content) { + log.info("Found existing download for \(id, privacy: .public) with state \(existingDownload.state, privacy: .public)") + + /// If we have an existing download, it must be in a resumable state and not completed. + /// If completed, we start a new one, otherwise we just resume it. + if existingDownload.isCompleted { + try _detach(existingDownload, persist: false, remove: true) + + download = createDownload() + } else { + guard existingDownload.state.isResumable else { + throw "A download already exists for \(content.id)." + } + + download = existingDownload + } + } else { + log.info("Creating new download for \(id, privacy: .public)") + + download = createDownload() + } + + return try await _start(download: download, attach: isNewDownload) + } + + @discardableResult + func _start(download: MediaDownload, attach: Bool) async throws -> MediaDownload { + let engine = try _engine(for: download) + + if attach { + try _attach(download, persist: true) + } + + try await engine.start(download) + + return download + } + +} + +// MARK: - In-Flight Download Management + +private extension MediaDownloadManager { + + func _restorePendingDownloads() async { + for engine in engines { + await _restorePendingTasks(for: engine) + } + } + + func _restorePendingTasks(for engine: E) async { + let pendingTasks = await engine.pendingDownloadTasks() + + guard !pendingTasks.isEmpty else { return } + + let name = String(describing: E.self) + + log.info("Restoring \(pendingTasks.count, privacy: .public) pending task(s) for \(name, privacy: .public)") + + for pendingTask in pendingTasks { + do { + let downloadID = try pendingTask.mediaDownloadID() + + let download = try metaStore.fetch(downloadID) + + try _attach(download, persist: false) + + log.info("Restored pending task \(downloadID, privacy: .public) on \(name, privacy: .public)") + } catch { + log.error("Error restoring pending download on \(name, privacy: .public): \(error, privacy: .public)") + + do { + try engine.cancel(pendingTask) + } catch { + log.error("Error cancelling failed restore task on \(name, privacy: .public): \(error, privacy: .public)") + } + } + } + } + + /// Removes persisted metadata for any downloads that don't have a corresponding download engine task. + func _purgeOrphanedDownloads() async { + do { + let persistedIdentifiers = try metaStore.persistedIdentifiers() + + for persistedIdentifier in persistedIdentifiers { + /// If we have a download state for the download ID, then we don't need to continue any further. + guard !self.mediaDownloads.contains(where: { $0.id == persistedIdentifier }) else { continue } + + /// If we don't have a download state, check each engine to see if we have a corresponding task, + /// in which case it's possible that this download is still valid but hasn't been attached to yet. + for engine in self.engines { + if await engine.fetchTask(for: persistedIdentifier) != nil { continue } + } + + log.warning("Purging orphaned download: \(persistedIdentifier, privacy: .public)") + + do { + try metaStore.remove(persistedIdentifier) + } catch { + log.error("Error purging orphaned download \(persistedIdentifier, privacy: .public): \(error, privacy: .public)") + } + } + } catch { + log.error("Failed to retrieve persisted download metadata: \(error, privacy: .public)") + } + } + + /// Starts monitoring the specified download, optionally persisting its metadata to the meta store. + @discardableResult + func _attach(_ download: MediaDownload, persist: Bool) throws -> MediaDownload { + let id = download.id + + log.info("Attach \(id, privacy: .public) (persist? \(persist, privacy: .public))") + + guard !mediaDownloads.contains(download) else { + if persist { + throw "Attach requested for download that's already being tracked: \(id)" + } else { + return download + } + } + + if persist { + try metaStore.persist(download) + } + + mediaDownloads.insert(download) + + download.$state.removeDuplicates().sink { [weak self] state in + guard let self else { return } + self._stateChanged(for: id, with: state) + } + .store(in: &download.cancellables) + + return download + } + + /// Stops monitoring the download. + /// - Parameters: + /// - download: The download to stop monitoring. + /// - persist: Whether the download should be removed from the metadata store. + /// - remove: Whether the download should be removed from the user-facing list of downloads. + func _detach(_ download: MediaDownload, persist: Bool, remove: Bool = false) throws { + let id = download.id + + log.info("Detach \(id, privacy: .public) (persist? \(persist, privacy: .public))") + + guard mediaDownloads.contains(download) else { + throw "Detach requested for download that's not being tracked: \(id)" + } + + download.cancellables.removeAll() + + if persist { + try metaStore.remove(id) + } + + if remove { + mediaDownloads.remove(download) + } + } + + func _stateChanged(for id: MediaDownload.ID, with state: MediaDownloadState) { + log.info("📣 State changed for \(id, privacy: .public): \(state, privacy: .public)") + + self._performDetachIfNeeded(for: id, state: state) + } + + /// Detaches the download if its completed or cancelled. + func _performDetachIfNeeded(for id: MediaDownload.ID, state: MediaDownloadState) { + guard state == .completed || state == .cancelled else { return } + + do { + let download = try _download(with: id) + + /// Move completed download file into place, failing the download if this process fails. + do { + try _moveIntoPlaceIfNeeded(download, state: state) + } catch { + log.error("Moving into place failed for \(id, privacy: .public): \(error, privacy: .public)") + + DispatchQueue.main.async { + download.state = .failed(message: error.localizedDescription) + } + return + } + + try _detach(download, persist: true) + } catch { + let detachReason = (state == .completed) ? "completed" : "cancelled" + let message = "Error detaching \(detachReason) download \(id): \(error)" + log.fault("\(message, privacy: .public)") + assertionFailure(message) + } + } + + func _moveIntoPlaceIfNeeded(_ download: MediaDownload, state: MediaDownloadState) throws { + let id = download.id + + #if DEBUG + log.debug("\(#function, privacy: .public) called for \(id, privacy: .public) with state \(state, privacy: .public)") + #endif + + guard state == .completed else { return } + + guard let temporaryLocalFileURL = download.temporaryLocalFileURL else { + throw "Download for \(id) completed without a local file being available." + } + + let destinationURL = directoryURL.appendingPathComponent(download.relativeLocalPath) + + let destinationFolderURL = destinationURL.deletingLastPathComponent() + + try destinationFolderURL.createIfNeeded() + + try fileManager.moveItem(at: temporaryLocalFileURL, to: destinationURL) + + log.debug("Successfully moved \(id, privacy: .public) into \(destinationURL.path)") + } + +} + +extension MediaDownloadState: CustomStringConvertible { + public var description: String { + switch self { + case .waiting: + return "⌛️ Waiting" + case .downloading(let progress): + return "🛞 Downloading (\(Int(progress * 100))%)" + case .paused: + return "✋ Paused" + case .failed(let message): + return "💔 Failed: \(message)" + case .completed: + return "✅ Completed" + case .cancelled: + return "🥺 Cancelled" + } + } +} + +private extension MediaDownload { + func shouldPersist(_ newState: MediaDownloadState?) -> Bool { + guard let newState, newState != state else { return false } + + /// Require a certain amount of progress change for persistence. + if case .downloading(let newProgress) = newState, case .downloading(let currentProgress) = self.state { + return abs(newProgress - currentProgress) >= 0.1 + } else { + return true + } + } +} diff --git a/WWDC/MediaDownload/MediaDownloadProtocols.swift b/WWDC/MediaDownload/MediaDownloadProtocols.swift new file mode 100644 index 00000000..6e0deddb --- /dev/null +++ b/WWDC/MediaDownload/MediaDownloadProtocols.swift @@ -0,0 +1,105 @@ +import Cocoa + +/// Describes a type of media content that can be downloaded. +public protocol DownloadableMediaVariant: Hashable { } + +/// Protocol adopted by types that have media that can be downloaded by ``MediaDownloadManager``. +public protocol DownloadableMediaContainer: Identifiable where ID == String { + /// The type that describes the supported downlodable media variants. + associatedtype MediaVariant: DownloadableMediaVariant + + /// All supported media download variants ordered from most to least preferred. + static var mediaDownloadVariants: [MediaVariant] { get } + + /// User-facing title for the content. + var title: String { get } + + /// Returns the local path where the downloaded variant should be written to, + /// relative to the root downloads directory. + func relativeLocalPath(for variant: MediaVariant) -> String? + + /// Returns the remote URL for downloading media of the specified variant. + func remoteDownloadURL(for variant: MediaVariant) -> URL? +} + +/// Adopted by types that implement the underlying mechanism for downloading a given media variant. +public protocol MediaDownloadEngine: AnyObject { + /// If the engine can check for support by file extension, return the set of supported extensions. + /// The default implementation for ``supports(_:)`` uses this. + var supportedExtensions: Set { get } + + /// Whether this engine can be used to perform the specified download. + func supports(_ download: MediaDownload) -> Bool + + /// Reference to the download manager responsible for this engine. + /// Doesn't have to be weak because the objects involved are effectivelly singletons. + var manager: MediaDownloadManager { get } + + /// Called by the download manager when activated. + init(manager: MediaDownloadManager) + + /// Invoked when the download manager is activated in order to get + /// the latest state of tasks that were active when the app was not running. + func pendingDownloadTasks() async -> [MediaDownloadTask] + + /// Begins downloading. + func start(_ download: MediaDownload) async throws + + /// Temporarily pauses the download. + func pause(_ download: MediaDownload) throws + + /// Resume a paused download. + func resume(_ download: MediaDownload) throws + + /// Cancels the download. + func cancel(_ download: MediaDownload) throws + + /// Cancels the task. + /// Called by the manager when restoring a download fails in order + /// to ensure that the task is purged. + func cancel(_ task: MediaDownloadTask) throws + + /// Retrieves an existing task for the specified media ID. + func fetchTask(for id: MediaDownload.ID) async -> MediaDownloadTask? +} + +/// Protocol adopted by types that represent a task that performs a ``MediaDownload``. +/// There's an extension on `URLSessionTask` implementing all requirements. +public protocol MediaDownloadTask { + func mediaDownloadID() throws -> MediaDownload.ID + func setMediaDownloadID(_ id: MediaDownload.ID) +} + +/// Protocol adopted by types that provide support for persisting ``MediaDownload`` objects. +/// An instance of such a type is used by ``MediaDownloadManager`` when restoring pending downloads between app launches. +public protocol MediaDownloadMetadataStorage: AnyObject { + /// Fetches the list of identifiers that have been persisted in the store. + func persistedIdentifiers() throws -> Set + + /// Fetches an existing media download with the specified id. + func fetch(_ id: MediaDownload.ID) throws -> MediaDownload + + /// Persists the download object. + func persist(_ download: MediaDownload) throws + + /// Removes the download with the specified id from storage. + func remove(_ id: MediaDownload.ID) throws +} + +// MARK: - Default Implementations + +public extension MediaDownloadEngine { + /// Returns `true` if the ``MediaDownload/relativeLocalPath`` has an extension included in ``supportedExtensions``. + func supports(_ download: MediaDownload) -> Bool { + guard let fileExtension = download.relativeLocalPath.components(separatedBy: ".").last?.lowercased() else { + assertionFailure("Attempting to check-in a download with a local path that doesn't have a file extension: \(download.relativeLocalPath)") + return false + } + + return supportedExtensions.contains(fileExtension) + } +} + +public extension DownloadableMediaContainer { + func downloadEngineType(for variant: MediaVariant) -> MediaDownloadEngine.Type? { nil } +} diff --git a/WWDC/MediaDownload/Support/Bundle+URLSessionID.swift b/WWDC/MediaDownload/Support/Bundle+URLSessionID.swift new file mode 100644 index 00000000..f52129fb --- /dev/null +++ b/WWDC/MediaDownload/Support/Bundle+URLSessionID.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Bundle { + func backgroundURLSessionIdentifier(suffix: String) -> String { + let prefix = bundleIdentifier ?? bundleURL.lastPathComponent + return "\(prefix).\(suffix)" + } +} diff --git a/WWDC/MediaDownload/Support/PreviewAndTestingSupport.swift b/WWDC/MediaDownload/Support/PreviewAndTestingSupport.swift new file mode 100644 index 00000000..ec2132ce --- /dev/null +++ b/WWDC/MediaDownload/Support/PreviewAndTestingSupport.swift @@ -0,0 +1,104 @@ +#if DEBUG +import Foundation + +extension URL { + static let testMP41 = URL(string: "https://devstreaming-cdn.apple.com/videos/wwdc/2022/10003/5/C8AAE478-A435-4DA4-8256-F32941E32204/downloads/wwdc2022-10003_hd.mp4")! + static let testMP42 = URL(string: "https://devstreaming-cdn.apple.com/videos/wwdc/2022/110360/3/95EF8495-F291-49FD-8958-276AC76C222D/downloads/wwdc2022-110360_hd.mp4")! +} + +public struct PreviewMediaContainer: DownloadableMediaContainer { + public static let mediaDownloadVariants = [MediaVariant.preview] + + public enum MediaVariant: String, DownloadableMediaVariant { + case preview + } + + public var title: String + public var id: String + public var remoteURL: URL + + public func relativeLocalPath(for variant: MediaVariant) -> String? { remoteURL.lastPathComponent } + + public func remoteDownloadURL(for variant: MediaVariant) -> URL? { remoteURL } + + public init(title: String, id: String, remoteURL: URL) { + self.title = title + self.id = id + self.remoteURL = remoteURL + } +} + +public extension PreviewMediaContainer { + static let preview1 = PreviewMediaContainer(title: "Preview Download 1", id: "preview-1", remoteURL: .testMP41) + static let preview2 = PreviewMediaContainer(title: "Preview Download 2", id: "preview-2", remoteURL: .testMP42) + + static var previewContainers: [PreviewMediaContainer] { [.preview1, .preview2] } +} + +public extension MediaDownload { + static var preview1: MediaDownload { + MediaDownload( + id: PreviewMediaContainer.preview1.id, + title: PreviewMediaContainer.preview1.title, + remoteURL: PreviewMediaContainer.preview1.remoteURL, + relativeLocalPath: PreviewMediaContainer.preview1.relativeLocalPath(for: .preview)! + ) + } + + static var preview2: MediaDownload { + MediaDownload( + id: PreviewMediaContainer.preview2.id, + title: PreviewMediaContainer.preview2.title, + remoteURL: PreviewMediaContainer.preview2.remoteURL, + relativeLocalPath: PreviewMediaContainer.preview2.relativeLocalPath(for: .preview)! + ) + } +} + +public extension MediaDownloadManager { + static let preview: MediaDownloadManager = { + let manager = MediaDownloadManager( + directoryURL: URL(fileURLWithPath: NSTemporaryDirectory()), + engines: [SimulatedMediaDownloadEngine.self], + metadataStorage: EphemeralMediaDownloadMetadataStore() + ) + + Task { + await manager.activate() + + do { + for container in PreviewMediaContainer.previewContainers { + try? manager.removeDownloadedMedia(for: container) + + try await manager.startDownload(for: container) + } + } catch { + preconditionFailure("Preview download manager failed to start simulated downloads: \(error)") + } + } + + return manager + }() +} + +public final class EphemeralMediaDownloadMetadataStore: MediaDownloadMetadataStorage { + private let cache = NSCache() + + public func persistedIdentifiers() throws -> Set { [] } + + public func fetch(_ id: MediaDownload.ID) throws -> MediaDownload { + guard let obj = cache.object(forKey: id as NSString) else { + throw "Metadata not found for \(id)" + } + return obj + } + + public func persist(_ download: MediaDownload) throws { + cache.setObject(download, forKey: download.id as NSString) + } + + public func remove(_ id: MediaDownload.ID) throws { + cache.removeObject(forKey: id as NSString) + } +} +#endif diff --git a/WWDC/MediaDownload/Support/String+Error.swift b/WWDC/MediaDownload/Support/String+Error.swift new file mode 100644 index 00000000..0a764259 --- /dev/null +++ b/WWDC/MediaDownload/Support/String+Error.swift @@ -0,0 +1,5 @@ +import Foundation + +extension String: LocalizedError { + public var errorDescription: String? { self } +} diff --git a/WWDC/MediaDownload/Support/URL+FileHelpers.swift b/WWDC/MediaDownload/Support/URL+FileHelpers.swift new file mode 100644 index 00000000..eded3e01 --- /dev/null +++ b/WWDC/MediaDownload/Support/URL+FileHelpers.swift @@ -0,0 +1,39 @@ +import Foundation + +extension URL { + var exists: Bool { FileManager.default.fileExists(atPath: path) } + + func createIfNeeded() throws { + guard !exists else { return } + + try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true) + } + + func creatingIfNeeded() throws -> URL { + try createIfNeeded() + + return self + } + + func deletingIfNeeded(allowDirectory: Bool = false) throws -> URL { + var isDir = ObjCBool(false) + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { return self } + + guard allowDirectory || !isDir.boolValue else { + throw "Refusing to delete existing directory at \(path)" + } + + try FileManager.default.removeItem(at: self) + + return self + } + + func moveToTemporaryLocation() throws -> URL { + let newTempLocation = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(lastPathComponent) + + try FileManager.default.moveItem(at: self, to: newTempLocation) + + return newTempLocation + } +} diff --git a/WWDC/MediaDownload/Support/URLSessionTask+Media.swift b/WWDC/MediaDownload/Support/URLSessionTask+Media.swift new file mode 100644 index 00000000..016555ff --- /dev/null +++ b/WWDC/MediaDownload/Support/URLSessionTask+Media.swift @@ -0,0 +1,14 @@ +import Foundation + +extension URLSessionTask: MediaDownloadTask { + public func mediaDownloadID() throws -> MediaDownload.ID { + guard let taskDescription else { throw "Media download task is missing a task description." } + return taskDescription + } + + public func setMediaDownloadID(_ id: MediaDownload.ID) { + taskDescription = id + } + + var debugDownloadID: String { taskDescription ?? "" } +} From 267c738d4adecb06524fb0a0f970b6aa00c90942 Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Mon, 3 Jun 2024 19:29:22 -0300 Subject: [PATCH 02/12] Initial download engine integration --- Packages/ConfCore/ConfCore/Session.swift | 15 +- Packages/ConfCore/ConfCore/SessionAsset.swift | 10 +- WWDC.xcodeproj/project.pbxproj | 12 + WWDC/AppCommandsReceiver.swift | 9 +- WWDC/AppCoordinator+SessionActions.swift | 12 +- ...r+SessionTableViewContextMenuActions.swift | 23 +- WWDC/AppCoordinator.swift | 8 +- WWDC/AppDelegate.swift | 5 + WWDC/Boot.swift | 2 + WWDC/DownloadManager.swift | 630 +----------------- WWDC/DownloadViewModel.swift | 6 +- WWDC/DownloadsManagementTableCellView.swift | 49 +- WWDC/DownloadsManagementViewController.swift | 14 +- WWDC/ExploreTabItemView.swift | 2 + WWDC/ExploreTabRootView.swift | 3 + .../MediaDownload/MediaDownloadManager+.swift | 59 ++ WWDC/MediaDownload/MediaDownloadManager.swift | 29 +- .../MediaDownloadProtocols.swift | 5 +- .../Support/MediaDownload+Sorting.swift | 25 + .../Support/PreviewAndTestingSupport.swift | 2 + .../Support/Session+Download.swift | 64 ++ WWDC/PlaybackViewModel.swift | 2 +- WWDC/Preferences.swift | 25 +- WWDC/SessionActionsViewController.swift | 19 +- WWDC/SessionViewModel.swift | 6 - WWDC/SessionsTableViewController.swift | 10 +- WWDC/ShelfViewController.swift | 2 +- WWDC/TitleBarButtonsViewController.swift | 4 +- 28 files changed, 328 insertions(+), 724 deletions(-) create mode 100644 WWDC/MediaDownload/MediaDownloadManager+.swift create mode 100644 WWDC/MediaDownload/Support/MediaDownload+Sorting.swift create mode 100644 WWDC/MediaDownload/Support/Session+Download.swift diff --git a/Packages/ConfCore/ConfCore/Session.swift b/Packages/ConfCore/ConfCore/Session.swift index 3ff5beb0..b5457909 100644 --- a/Packages/ConfCore/ConfCore/Session.swift +++ b/Packages/ConfCore/ConfCore/Session.swift @@ -146,7 +146,7 @@ public class Session: Object, Decodable { // MARK: - Decodable private enum AssetCodingKeys: String, CodingKey { - case id, year, title, downloadHD, downloadSD, slides, hls, images, shelf, duration + case id, year, title, downloadHD, downloadSD, downloadHLS, slides, hls, images, shelf, duration } private enum SessionCodingKeys: String, CodingKey { @@ -230,6 +230,19 @@ public class Session: Object, Decodable { self.assets.append(slidesAsset) } + + if let downloadHLS = try assetContainer.decodeIfPresent(String.self, forKey: .downloadHLS) { + let downloadHLSVideo = SessionAsset() + downloadHLSVideo.rawAssetType = SessionAssetType.downloadHLSVideo.rawValue + downloadHLSVideo.remoteURL = downloadHLS + downloadHLSVideo.year = Int(eventYear) ?? -1 + downloadHLSVideo.sessionId = downloadHLS + + let filename = "\(title).movpkg" + downloadHLSVideo.relativeLocalURL = "\(eventYear)/\(filename)" + + self.assets.append(downloadHLSVideo) + } } func decodeRelatedIfPresent() throws { diff --git a/Packages/ConfCore/ConfCore/SessionAsset.swift b/Packages/ConfCore/ConfCore/SessionAsset.swift index 66f9c86d..f4e03ab4 100644 --- a/Packages/ConfCore/ConfCore/SessionAsset.swift +++ b/Packages/ConfCore/ConfCore/SessionAsset.swift @@ -13,6 +13,7 @@ public enum SessionAssetType: String { case none case hdVideo = "WWDCSessionAssetTypeHDVideo" case sdVideo = "WWDCSessionAssetTypeSDVideo" + case downloadHLSVideo = "WWDCSessionAssetTypeDownloadHLSVideo" case image = "WWDCSessionAssetTypeShelfImage" case slides = "WWDCSessionAssetTypeSlidesPDF" case streamingVideo = "WWDCSessionAssetTypeStreamingVideo" @@ -23,14 +24,7 @@ public enum SessionAssetType: String { /// Session assets are resources associated with sessions, like videos, PDFs and useful links public class SessionAsset: Object, Decodable { - /// The type of asset: - /// - /// - WWDCSessionAssetTypeHDVideo - /// - WWDCSessionAssetTypeSDVideo - /// - WWDCSessionAssetTypeShelfImage - /// - WWDCSessionAssetTypeSlidesPDF - /// - WWDCSessionAssetTypeStreamingVideo - /// - WWDCSessionAssetTypeWebpageURL + /// The type of asset. @objc internal dynamic var rawAssetType = "" { didSet { identifier = generateIdentifier() diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index dc6ec708..bb6235be 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -183,6 +183,9 @@ F4578D592A2659C5005B311A /* LiveStreamOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4578D582A2659C5005B311A /* LiveStreamOverlay.swift */; }; F4578D5B2A2659F0005B311A /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4578D5A2A2659F0005B311A /* VideoPlayer.swift */; }; F4578D9F2A26A218005B311A /* WWDCAppCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4578D9E2A26A218005B311A /* WWDCAppCommand.swift */; }; + F46E0AE02C0E6CB20077A5E0 /* MediaDownload+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46E0ADF2C0E6CB20077A5E0 /* MediaDownload+Sorting.swift */; }; + F46E0AE22C0E6DA80077A5E0 /* Session+Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46E0AE12C0E6DA80077A5E0 /* Session+Download.swift */; }; + F46E0AE42C0E6EF80077A5E0 /* MediaDownloadManager+.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46E0AE32C0E6EF80077A5E0 /* MediaDownloadManager+.swift */; }; F474DEC926737EFA00B28B31 /* SharePlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474DEC826737EFA00B28B31 /* SharePlayManager.swift */; }; F474DECD2673801500B28B31 /* WatchWWDCActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474DECC2673801500B28B31 /* WatchWWDCActivity.swift */; }; F4777ABA2A2A2F6C00A09179 /* WWDCAgentRemover.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4777AB92A2A2F6C00A09179 /* WWDCAgentRemover.swift */; }; @@ -467,6 +470,9 @@ F4578D582A2659C5005B311A /* LiveStreamOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveStreamOverlay.swift; sourceTree = ""; }; F4578D5A2A2659F0005B311A /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; F4578D9E2A26A218005B311A /* WWDCAppCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WWDCAppCommand.swift; sourceTree = ""; }; + F46E0ADF2C0E6CB20077A5E0 /* MediaDownload+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaDownload+Sorting.swift"; sourceTree = ""; }; + F46E0AE12C0E6DA80077A5E0 /* Session+Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Session+Download.swift"; sourceTree = ""; }; + F46E0AE32C0E6EF80077A5E0 /* MediaDownloadManager+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaDownloadManager+.swift"; sourceTree = ""; }; F474DEC826737EFA00B28B31 /* SharePlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayManager.swift; sourceTree = ""; }; F474DECC2673801500B28B31 /* WatchWWDCActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchWWDCActivity.swift; sourceTree = ""; }; F4777AB92A2A2F6C00A09179 /* WWDCAgentRemover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WWDCAgentRemover.swift; sourceTree = ""; }; @@ -1134,6 +1140,7 @@ F486B3062C0E69E60066749F /* MediaDownload.swift */, F486B2FA2C0E69E60066749F /* MediaDownloadManager.swift */, F486B2F82C0E69E60066749F /* MediaDownloadProtocols.swift */, + F46E0AE32C0E6EF80077A5E0 /* MediaDownloadManager+.swift */, ); path = MediaDownload; sourceTree = ""; @@ -1156,6 +1163,8 @@ F486B3022C0E69E60066749F /* String+Error.swift */, F486B3032C0E69E60066749F /* URL+FileHelpers.swift */, F486B3042C0E69E60066749F /* URLSessionTask+Media.swift */, + F46E0ADF2C0E6CB20077A5E0 /* MediaDownload+Sorting.swift */, + F46E0AE12C0E6DA80077A5E0 /* Session+Download.swift */, ); path = Support; sourceTree = ""; @@ -1538,6 +1547,7 @@ 4DBA2F7620FE71BF00ED0253 /* DownloadsStatusButton.swift in Sources */, DD6E06F81EDBC62D000EAEA4 /* SessionTranscriptViewController.swift in Sources */, DD36A4B01E478C6A00B2EA88 /* AppDelegate.swift in Sources */, + F46E0AE02C0E6CB20077A5E0 /* MediaDownload+Sorting.swift in Sources */, F486B30F2C0E69E60066749F /* PreviewAndTestingSupport.swift in Sources */, DDA7B7182482EB8900F86668 /* PlaybackPreferencesViewController.swift in Sources */, DDF5A5092487066200135E70 /* ClipComposition.swift in Sources */, @@ -1577,6 +1587,7 @@ DD2E27881EAC2CCB0009D7B6 /* ShelfView.swift in Sources */, DDEDFCF51ED9FF8A002477C8 /* WWDCSegmentedControl.swift in Sources */, 91EF6A2A2A33FBF8003A71A3 /* Realm+Combine.swift in Sources */, + F46E0AE42C0E6EF80077A5E0 /* MediaDownloadManager+.swift in Sources */, DDF32EB71EBE65930028E39D /* AppCoordinator+UserActivity.swift in Sources */, DD4873D320AE5FF3005033CE /* AppCoordinator+RelatedSessions.swift in Sources */, DDEDFCEF1ED92785002477C8 /* MultipleChoiceFilter.swift in Sources */, @@ -1590,6 +1601,7 @@ F486B3132C0E69E60066749F /* MediaDownload.swift in Sources */, DDB28F911EAD2A050077703F /* WWDCAlert.swift in Sources */, DD90CDC81ED77A3900CADE86 /* SearchFiltersViewController.swift in Sources */, + F46E0AE22C0E6DA80077A5E0 /* Session+Download.swift in Sources */, DDF32EBB1EBE65DD0028E39D /* AppCoordinator+SessionActions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/WWDC/AppCommandsReceiver.swift b/WWDC/AppCommandsReceiver.swift index e46fc4f0..03ef9ba3 100644 --- a/WWDC/AppCommandsReceiver.swift +++ b/WWDC/AppCommandsReceiver.swift @@ -13,6 +13,7 @@ import OSLog final class AppCommandsReceiver: Logging { static let log = makeLogger(subsystem: "io.wwdc.app") + @MainActor // swiftlint:disable:next cyclomatic_complexity func handle(_ command: WWDCAppCommand, storage: Storage) -> DeepLink? { log.debug("\(#function, privacy: .public) \(String(describing: command))") @@ -40,14 +41,14 @@ final class AppCommandsReceiver: Logging { return nil case .download: guard let session = command.session(in: storage) else { return nil } - - DownloadManager.shared.download([session]) - + + MediaDownloadManager.shared.download([session]) + return nil case .cancelDownload: guard let session = command.session(in: storage) else { return nil } - DownloadManager.shared.cancelDownloads([session]) + MediaDownloadManager.shared.cancelDownload(for: [session]) return nil case .revealVideo: diff --git a/WWDC/AppCoordinator+SessionActions.swift b/WWDC/AppCoordinator+SessionActions.swift index 86878566..65504922 100644 --- a/WWDC/AppCoordinator+SessionActions.swift +++ b/WWDC/AppCoordinator+SessionActions.swift @@ -15,10 +15,11 @@ import OSLog extension AppCoordinator: SessionActionsViewControllerDelegate { + @MainActor func sessionActionsDidSelectCancelDownload(_ sender: NSView?) { guard let viewModel = activeTabSelectedSessionViewModel else { return } - DownloadManager.shared.cancelDownloads([viewModel.session]) + MediaDownloadManager.shared.cancelDownload(for: [viewModel.session]) } func sessionActionsDidSelectFavorite(_ sender: NSView?) { @@ -37,10 +38,11 @@ extension AppCoordinator: SessionActionsViewControllerDelegate { NSWorkspace.shared.open(url) } + @MainActor func sessionActionsDidSelectDownload(_ sender: NSView?) { guard let viewModel = activeTabSelectedSessionViewModel else { return } - DownloadManager.shared.download([viewModel.session]) + MediaDownloadManager.shared.download([viewModel.session]) } func sessionActionsDidSelectDeleteDownload(_ sender: NSView?) { @@ -63,7 +65,11 @@ extension AppCoordinator: SessionActionsViewControllerDelegate { switch choice { case .yes: - DownloadManager.shared.deleteDownloadedFile(for: viewModel.session) + do { + try MediaDownloadManager.shared.removeDownloadedMedia(for: viewModel.session) + } catch { + NSAlert(error: error).runModal() + } case .no: break } diff --git a/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift b/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift index 1e3de803..61ea3d5e 100644 --- a/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift +++ b/WWDC/AppCoordinator+SessionTableViewContextMenuActions.swift @@ -35,6 +35,7 @@ extension AppCoordinator: SessionsTableViewControllerDelegate { storage.setFavorite(false, onSessionsWithIDs: viewModels.map({ $0.session.identifier })) } + @MainActor func sessionTableViewContextMenuActionDownload(viewModels: [SessionViewModel]) { if viewModels.count > 5 { // asking to download many videos, warn @@ -56,27 +57,27 @@ extension AppCoordinator: SessionsTableViewControllerDelegate { guard case .yes = choice else { return } } - DownloadManager.shared.download(viewModels.map { $0.session }) + MediaDownloadManager.shared.download(viewModels.map(\.session)) } + @MainActor func sessionTableViewContextMenuActionCancelDownload(viewModels: [SessionViewModel]) { - viewModels.forEach { viewModel in - - guard DownloadManager.shared.isDownloading(viewModel.session) else { return } - - DownloadManager.shared.deleteDownloadedFile(for: viewModel.session) - } + let cancellableDownloads = viewModels.map(\.session).filter { MediaDownloadManager.shared.isDownloadingMedia(for: $0) } + + MediaDownloadManager.shared.cancelDownload(for: cancellableDownloads) } + @MainActor func sessionTableViewContextMenuActionRemoveDownload(viewModels: [SessionViewModel]) { - viewModels.forEach { viewModel in - DownloadManager.shared.deleteDownloadedFile(for: viewModel.session) - } + let deletableDownloads = viewModels.map(\.session).filter { MediaDownloadManager.shared.hasDownloadedMedia(for: $0) } + + MediaDownloadManager.shared.delete(deletableDownloads) } + @MainActor func sessionTableViewContextMenuActionRevealInFinder(viewModels: [SessionViewModel]) { guard let firstSession = viewModels.first?.session else { return } - guard let localURL = DownloadManager.shared.downloadedFileURL(for: firstSession) else { return } + guard let localURL = MediaDownloadManager.shared.downloadedFileURL(for: firstSession) else { return } NSWorkspace.shared.selectFile(localURL.path, inFileViewerRootedAtPath: localURL.deletingLastPathComponent().path) } diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index 381289a3..c3fd0d9d 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -96,6 +96,7 @@ final class AppCoordinator: Logging, Signposting { } } + @MainActor init(windowController: MainWindowController, storage: Storage, syncEngine: SyncEngine) { let signpostState = Self.signposter.beginInterval("initialization", id: Self.signposter.makeSignpostID(), "begin init") self.storage = storage @@ -112,7 +113,7 @@ final class AppCoordinator: Logging, Signposting { ) self.searchCoordinator = searchCoordinator - DownloadManager.shared.start(with: storage) + MediaDownloadManager.shared.activate() liveObserver = LiveObserver(dateProvider: today, storage: storage, syncEngine: syncEngine) @@ -178,7 +179,7 @@ final class AppCoordinator: Logging, Signposting { NSApp.isAutomaticCustomizeTouchBarMenuItemEnabled = true let buttonsController = TitleBarButtonsViewController( - downloadManager: DownloadManager.shared, + downloadManager: .shared, storage: storage ) windowController.titleBarViewController.statusViewController = buttonsController @@ -209,7 +210,8 @@ final class AppCoordinator: Logging, Signposting { }.store(in: &cancellables) NotificationCenter.default.publisher(for: .SyncEngineDidSyncSessionsAndSchedule).receive(on: DispatchQueue.main).sink { [weak self] note in guard self?.checkSyncEngineOperationSucceededAndShowError(note: note) == true else { return } - DownloadManager.shared.syncWithFileSystem() + #warning("TODO: Reimplement with new download manager (or remove)") +// DownloadManager.shared.syncWithFileSystem() }.store(in: &cancellables) NotificationCenter.default.publisher(for: .WWDCEnvironmentDidChange).receive(on: DispatchQueue.main).sink { _ in self.refresh(nil) diff --git a/WWDC/AppDelegate.swift b/WWDC/AppDelegate.swift index ebe2f124..d82f48c7 100644 --- a/WWDC/AppDelegate.swift +++ b/WWDC/AppDelegate.swift @@ -88,6 +88,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { private var storage: Storage? private var syncEngine: SyncEngine? + @MainActor private func startupUI(using storage: Storage, syncEngine: SyncEngine) { self.storage = storage self.syncEngine = syncEngine @@ -157,6 +158,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { coordinator?.receiveNotification(with: userInfo) } + @MainActor @objc func handleURLEvent(_ event: NSAppleEventDescriptor?, replyEvent: NSAppleEventDescriptor?) { guard let event = event else { return } guard let urlString = event.paramDescriptor(forKeyword: UInt32(keyDirectObject))?.stringValue else { return } @@ -165,6 +167,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, Logging { openURL(url) } + @MainActor private func openURL(_ url: URL) { if let command = WWDCAppCommand(from: url) { handle(command) @@ -277,10 +280,12 @@ extension AppDelegate: SUUpdaterDelegate { } extension AppDelegate { + @MainActor static func run(_ command: WWDCAppCommand) { (NSApp.delegate as? Self)?.handle(command, assumeSafe: true) } + @MainActor func handle(_ command: WWDCAppCommand, assumeSafe: Bool = false) { if command.isForeground { DispatchQueue.main.async { NSApp.activate(ignoringOtherApps: true) } diff --git a/WWDC/Boot.swift b/WWDC/Boot.swift index c17bb6ee..f01c5a0a 100644 --- a/WWDC/Boot.swift +++ b/WWDC/Boot.swift @@ -237,3 +237,5 @@ extension NSApplication { exit(0) } } + +extension NSWorkspace: @unchecked Sendable { } diff --git a/WWDC/DownloadManager.swift b/WWDC/DownloadManager.swift index 036c34b5..2962e3fb 100644 --- a/WWDC/DownloadManager.swift +++ b/WWDC/DownloadManager.swift @@ -12,628 +12,10 @@ import ConfCore import RealmSwift import OSLog -enum DownloadStatus { - case none - case downloading(DownloadManager.DownloadInfo) - case paused(DownloadManager.DownloadInfo) - case cancelled - case finished - case failed(Error?) -} - -final class DownloadManager: NSObject, Logging { - - // Changing this dynamically isn't supported. Delete all downloads when switching - // from one quality to another otherwise you'll encounter minor unexpected behavior - static let downloadQuality = SessionAssetType.hdVideo - - static let log = makeLogger() - private let configuration = URLSessionConfiguration.background(withIdentifier: "WWDC Video Downloader") - private var backgroundSession: Foundation.URLSession! - private var downloadTasks: [String: Download] = [:] { - didSet { - downloads = Array(downloadTasks.values) - } - } - @Published private(set) var downloads: [Download] = [] - private let defaults = UserDefaults.standard - - var storage: Storage! - - static let shared: DownloadManager = DownloadManager() - - override init() { - super.init() - - // TODO: Check a little harder into whether we can keep the delegate methods off the main thread. - // TODO: There are actually UI perf concerns when doing a lot of downloads - backgroundSession = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) - } - - // MARK: - Session-based Public API - - func start(with storage: Storage) { - self.storage = storage - - backgroundSession.getTasksWithCompletionHandler { _, _, pendingTasks in - for task in pendingTasks { - if let key = task.originalRequest?.url?.absoluteString, - let remoteURL = URL(string: key), - let asset = storage.asset(with: remoteURL), - let session = asset.session.first { - - self.downloadTasks[key] = Download(session: SessionIdentifier(session.identifier), remoteURL: key, task: task) - } else { - // We have a task that is not associated with a session at all, lets cancel it - task.cancel() - } - } - } - - _ = NotificationCenter.default.addObserver(forName: .LocalVideoStoragePathPreferenceDidChange, object: nil, queue: nil) { _ in - self.monitorDownloadsFolder() - } - - updateDownloadedFlagsOfPreviouslyDownloaded() - monitorDownloadsFolder() - } - - func download(_ sessions: [Session], resumeIfPaused: Bool = true) { - // This function is optimized so that many downloads can be started simultaneously and efficiently - - // Step 1: Collect all the remote URLs on the main thread for Realm reasons - var sessionURLMap = [SessionIdentifier: String]() - for session in sessions { - guard let asset = session.asset(ofType: DownloadManager.downloadQuality) else { continue } - - let url = asset.remoteURL - - if resumeIfPaused && isDownloading(url) { - _ = resumeDownload(url) - continue - } - - if hasDownloadedVideo(asset: asset) { - continue - } - - sessionURLMap[SessionIdentifier(session.identifier)] = url - } - - // Step 2. Move to the background and start the downloads - DispatchQueue.global(qos: .background).async { - var successfullyStartedTasks = [String: Download]() - for (sessionID, urlString) in sessionURLMap { - if let task = URL(string: urlString).map({ self.backgroundSession.downloadTask(with: $0) }), - let key = task.originalRequest?.url?.absoluteString { - - successfullyStartedTasks[key] = Download(session: sessionID, remoteURL: key, task: task) - } else { - NotificationCenter.default.post(name: .DownloadManagerDownloadFailed, object: urlString) - } - } - - // Step 3. Update the downloadTasks in 1 shot on the main thread - // This prevents observers from being thrashed by adding tasks individually in a loop - // which leads to application spins. - DispatchQueue.main.async { - self.downloadTasks.merge(successfullyStartedTasks, uniquingKeysWith: { a, b in b }) - - for (url, download) in successfullyStartedTasks { - download.task?.resume() - NotificationCenter.default.post(name: .DownloadManagerDownloadStarted, object: url) - } - } - } - } - - func cancelDownloads(_ sessions: [Session]) { - var urls = [String]() - for session in sessions { - guard let url = session.asset(ofType: DownloadManager.downloadQuality)?.remoteURL else { continue } - urls.append(url) - } - - return cancelDownloads(urls) - } - - func isDownloading(_ session: Session) -> Bool { - guard let url = session.asset(ofType: DownloadManager.downloadQuality)?.remoteURL else { return false } - - return isDownloading(url) - } - - func isDownloadable(_ session: Session) -> Bool { - return session.asset(ofType: DownloadManager.downloadQuality) != nil - } - - func downloadedFileURL(for session: Session) -> URL? { - guard let asset = session.asset(ofType: DownloadManager.downloadQuality) else { return nil } - - let path = localStoragePath(for: asset) - - guard FileManager.default.fileExists(atPath: path) else { return nil } - - return URL(fileURLWithPath: path) - } - - func hasDownloadedVideo(session: Session) -> Bool { - return downloadedFileURL(for: session) != nil - } - - func hasDownloadedVideo(asset: SessionAsset) -> Bool { - let path = localStoragePath(for: asset) - - return FileManager.default.fileExists(atPath: path) - } - - func deleteDownloadedFile(for session: Session) { - guard let asset = session.asset(ofType: DownloadManager.downloadQuality) else { return } - - do { - try removeDownload(asset.remoteURL) - } catch { - WWDCAlert.show(with: error) - } - } - - func downloadStatusObservable(for download: Download) -> AnyPublisher? { - guard let remoteURL = URL(string: download.remoteURL) else { return nil } - guard let downloadingAsset = storage.asset(with: remoteURL) else { return nil } - - return downloadStatusObservable(for: downloadingAsset) - } - - func downloadStatusObservable(for session: Session) -> AnyPublisher? { - guard let asset = session.asset(ofType: DownloadManager.downloadQuality) else { return nil } - - return downloadStatusObservable(for: asset) - } - - private func downloadStatusObservable(for asset: SessionAsset) -> AnyPublisher? { - // TODO: This function could probably be improved. Too much duplication, also I don't know that capturing this state locally like this - // TODO: is needed and it feels odd - var latestInfo: DownloadInfo = .unknown - - let currentDownloadState: () -> DownloadStatus = { - if let download = self.downloadTasks[asset.remoteURL], - let task = download.task { - latestInfo = DownloadInfo(task: task) - - switch task.state { - case .running: - return .downloading(latestInfo) - case .suspended: - return .paused(latestInfo) - case .canceling: - return .cancelled - case .completed: - return .finished - @unknown default: - assertionFailure("An unexpected case was discovered on an non-frozen obj-c enum") - return .downloading(latestInfo) - } - } else if self.hasDownloadedVideo(remoteURL: asset.remoteURL) { - return .finished - } else { - return .none - } - } - - let nc = NotificationCenter.default - let fileDeleted = nc.publisher(for: .DownloadManagerFileDeletedNotification, filteredBy: asset.relativeLocalURL).map { _ in - DownloadStatus.none - } - let fileAdded = nc.publisher(for: .DownloadManagerFileAddedNotification, filteredBy: asset.relativeLocalURL).map { _ in - DownloadStatus.finished - } - - let progress = nc.publisher(for: .DownloadManagerDownloadProgressChanged, filteredBy: asset.remoteURL).map { note in - if let info = note.userInfo?["info"] as? DownloadInfo { - latestInfo = info - if info.taskState == .suspended { - // We can get progress updates that were from while the task was suspending - return DownloadStatus.paused(info) - } else { - return DownloadStatus.downloading(info) - } - } else { - return DownloadStatus.downloading(.unknown) - } - } - - let paused = nc.publisher(for: .DownloadManagerDownloadPaused, filteredBy: asset.remoteURL).map { _ in - DownloadStatus.paused(latestInfo) - } - - let resumed = nc.publisher(for: .DownloadManagerDownloadResumed, filteredBy: asset.remoteURL).map { _ in - DownloadStatus.downloading(latestInfo) - } - - let cancelled = nc.publisher(for: .DownloadManagerDownloadCancelled, filteredBy: asset.remoteURL).map { _ in - DownloadStatus.cancelled - } - - let finished = nc.publisher(for: .DownloadManagerDownloadFinished, filteredBy: asset.remoteURL).map { _ in - DownloadStatus.finished - } - - let failed = nc.publisher(for: .DownloadManagerDownloadFailed, filteredBy: asset.remoteURL).map { notification in - let error = notification.userInfo?["error"] as? Error - return DownloadStatus.failed(error) - } - - return Just(currentDownloadState()) - .merge(with: fileDeleted) - .merge(with: fileAdded) - .merge(with: progress) - .merge(with: paused) - .merge(with: resumed) - .merge(with: finished) - .merge(with: cancelled) - .merge(with: failed) - .eraseToAnyPublisher() - } - - // MARK: - URL-based Internal API - - fileprivate func localStoragePath(for asset: SessionAsset) -> String { - return Preferences.shared.localVideoStorageURL.appendingPathComponent(asset.relativeLocalURL).path - } - - private func pauseDownload(_ url: String) -> Bool { - if let download = downloadTasks[url] { - download.pause() - return true - } - - log.error("Unable to pause download of \(url, privacy: .public) because there's no task for that URL") - - return false - } - - private func resumeDownload(_ url: String) -> Bool { - if let download = downloadTasks[url], download.state == .suspended { - download.resume() - return true - } - - log.error("Unable to resume download of \(url, privacy: .public) because there's no task for that URL") - - return false - } - - private func cancelDownloads(_ urls: [String]) { - for url in urls { - if let download = downloadTasks[url] { - download.task?.cancel() - return - } - - log.error("Unable to cancel download of \(url, privacy: .public) because there's no task for that URL") - } - } - - private func isDownloading(_ url: String) -> Bool { - return downloadTasks[url] != nil - } - - /// Given a remote URL, determines the asset that references the remote URL - /// and returns a local URL, as a string, where the file can be downloaded - /// to or where you'd expect to find it if it has already been downloaded - private func lookupAssetLocalVideoPath(remoteURL: String) -> String? { - guard let url = URL(string: remoteURL) else { return nil } - - guard let asset = storage.asset(with: url) else { - return nil - } - - let path = localStoragePath(for: asset) - - return path - } - - private func hasDownloadedVideo(remoteURL url: String) -> Bool { - guard let path = lookupAssetLocalVideoPath(remoteURL: url) else { return false } - - return FileManager.default.fileExists(atPath: path) - } - - enum RemoveDownloadError: Error { - case notDownloaded - case fileSystem(Error) - case internalError(String) - } - - private func removeDownload(_ url: String) throws { - if isDownloading(url) { - cancelDownloads([url]) - return - } - - if hasDownloadedVideo(remoteURL: url) { - guard let path = lookupAssetLocalVideoPath(remoteURL: url) else { - throw RemoveDownloadError.internalError("Unable to generate local video path from remote URL") - } - - do { - try FileManager.default.removeItem(atPath: path) - } catch { - throw RemoveDownloadError.fileSystem(error) - } - } else { - throw RemoveDownloadError.notDownloaded - } - } - - // MARK: - File observation - - fileprivate var topFolderMonitor: DTFolderMonitor! - fileprivate var subfoldersMonitors: [DTFolderMonitor] = [] - fileprivate var existingVideoFiles = [String]() - - func syncWithFileSystem() { - let videosPath = Preferences.shared.localVideoStorageURL.path - updateDownloadedFlagsByEnumeratingFilesAtPath(videosPath) - } - - private func monitorDownloadsFolder() { - if topFolderMonitor != nil { - topFolderMonitor.stopMonitoring() - topFolderMonitor = nil - } - - subfoldersMonitors.forEach({ $0.stopMonitoring() }) - subfoldersMonitors.removeAll() - - let url = Preferences.shared.localVideoStorageURL - - topFolderMonitor = DTFolderMonitor(for: url) { [unowned self] in - self.setupSubdirectoryMonitors(on: url) - - self.updateDownloadedFlagsByEnumeratingFilesAtPath(url.path) - } - - setupSubdirectoryMonitors(on: url) - - topFolderMonitor.startMonitoring() - } - - private func setupSubdirectoryMonitors(on mainDirURL: URL) { - subfoldersMonitors.forEach({ $0.stopMonitoring() }) - subfoldersMonitors.removeAll() - - mainDirURL.subDirectories.forEach { subdir in - guard let monitor = DTFolderMonitor(for: subdir, block: { [unowned self] in - self.updateDownloadedFlagsByEnumeratingFilesAtPath(mainDirURL.path) - }) else { return } - - subfoldersMonitors.append(monitor) - - monitor.startMonitoring() - } - } - - fileprivate func updateDownloadedFlagsOfPreviouslyDownloaded() { - let expectedOnDisk = storage.sessions.filter(NSPredicate(format: "isDownloaded == true")) - var notPresent = [String]() - - for session in expectedOnDisk { - if let asset = session.asset(ofType: DownloadManager.downloadQuality) { - if !hasDownloadedVideo(asset: asset) { - notPresent.append(asset.relativeLocalURL) - } - } - } - - storage.updateDownloadedFlag(false, forAssetsAtPaths: notPresent) - notPresent.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } - } - - /// Updates the downloaded status for the sessions on the database based on the existence of the downloaded video file - /// - /// This function is only ever called with the main destination directory, despite what the rest - /// of the architecture might suggest. The subfolder monitors just force the entire hierarchy to be - /// re-enumerated. This function has signifcant side effects. - fileprivate func updateDownloadedFlagsByEnumeratingFilesAtPath(_ path: String) { - guard let enumerator = FileManager.default.enumerator(atPath: path) else { return } - - var files: [String] = [] - - while let path = enumerator.nextObject() as? String { - if enumerator.level > 2 { enumerator.skipDescendants() } - files.append(path) - } - - guard !files.isEmpty else { return } - - storage.updateDownloadedFlag(true, forAssetsAtPaths: files) - - files.forEach { NotificationCenter.default.post(name: .DownloadManagerFileAddedNotification, object: $0) } - - if existingVideoFiles.count == 0 { - existingVideoFiles = files - return - } - - let removedFiles = existingVideoFiles.filter { !files.contains($0) } - - storage.updateDownloadedFlag(false, forAssetsAtPaths: removedFiles) - - removedFiles.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } - - // This is now the list of files - existingVideoFiles = files - } - - // MARK: Teardown - - deinit { - if topFolderMonitor != nil { - topFolderMonitor.stopMonitoring() - } - } -} - -extension DownloadManager: URLSessionDownloadDelegate, URLSessionTaskDelegate { - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - guard let originalURL = downloadTask.originalRequest?.url else { return } - - let originalAbsoluteURLString = originalURL.absoluteString - - guard let localPath = lookupAssetLocalVideoPath(remoteURL: originalAbsoluteURLString) else { return } - let destinationUrl = URL(fileURLWithPath: localPath) - let destinationDir = destinationUrl.deletingLastPathComponent() - - do { - if !FileManager.default.fileExists(atPath: destinationDir.path) { - try FileManager.default.createDirectory(at: destinationDir, withIntermediateDirectories: true, attributes: nil) - } - - try FileManager.default.moveItem(at: location, to: destinationUrl) - - downloadTasks.removeValue(forKey: originalAbsoluteURLString) - - NotificationCenter.default.post(name: .DownloadManagerDownloadFinished, object: originalAbsoluteURLString) - } catch { - NotificationCenter.default.post(name: .DownloadManagerDownloadFailed, object: originalAbsoluteURLString, userInfo: ["error": error]) - } - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let originalURL = task.originalRequest?.url else { return } - - let originalAbsoluteURLString = originalURL.absoluteString - - downloadTasks.removeValue(forKey: originalAbsoluteURLString) - - if let error = error { - switch error { - case let error as URLError where error.code == URLError.cancelled: - NotificationCenter.default.post(name: .DownloadManagerDownloadCancelled, object: originalAbsoluteURLString) - default: - NotificationCenter.default.post(name: .DownloadManagerDownloadFailed, object: originalAbsoluteURLString, userInfo: ["error": error]) - } - } - } - - struct DownloadInfo { - let totalBytesWritten: Int64 - let totalBytesExpectedToWrite: Int64 - let progress: Double - let taskState: URLSessionDownloadTask.State? - - init(task: URLSessionTask) { - totalBytesExpectedToWrite = task.countOfBytesExpectedToReceive - totalBytesWritten = task.countOfBytesReceived - progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) - taskState = task.state - } - - init(totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64, progress: Double) { - self.totalBytesWritten = totalBytesWritten - self.totalBytesExpectedToWrite = totalBytesExpectedToWrite - self.progress = progress - self.taskState = nil - } - - static let unknown = DownloadInfo(totalBytesWritten: 0, totalBytesExpectedToWrite: 0, progress: -1) - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - guard let originalURL = downloadTask.originalRequest?.url?.absoluteString else { return } - - let info = DownloadInfo(task: downloadTask) - NotificationCenter.default.post(name: .DownloadManagerDownloadProgressChanged, object: originalURL, userInfo: ["info": info]) - } -} - -extension DownloadManager { - - struct Download: Equatable { - // Equatable can't be synthesized with a `weak` property for some reason - static func == (lhs: DownloadManager.Download, rhs: DownloadManager.Download) -> Bool { - return lhs.session == rhs.session && lhs.remoteURL == rhs.remoteURL && lhs.task == rhs.task - } - - let session: SessionIdentifier - fileprivate var remoteURL: String - fileprivate weak var task: URLSessionDownloadTask? - - func pause() { - guard let task = task else { return } - task.suspend() - NotificationCenter.default.post(name: .DownloadManagerDownloadPaused, object: remoteURL) - } - - func resume() { - guard let task = task else { return } - task.resume() - NotificationCenter.default.post(name: .DownloadManagerDownloadResumed, object: remoteURL) - } - - func cancel() { - guard let task = task else { return } - task.cancel() - } - - var state: URLSessionTask.State { - return task?.state ?? .canceling - } - - // swiftlint:disable:next cyclomatic_complexity - static func sortingFunction(lhs: Download, rhs: Download) -> Bool { - guard let left = lhs.task, let right = rhs.task else { return false } - - switch ((left.countOfBytesExpectedToReceive, left.state), (right.countOfBytesExpectedToReceive, right.state)) { - // 1. known and running - case ((1..., .running), (1..., .running)): - break - case ((1..., .running), _): - return true - case (_, (1..., .running)): - return false - - // 2. known and suspended are next - case ((1..., .suspended), (1..., .suspended)): - break - case ((1..., .suspended), _): - return true - case (_, (1..., .suspended)): - return false - - // 3. Unknown & suspended - case ((0, .suspended), (0, .suspended)): - break - case ((0, .suspended), _): - return true - case (_, (0, .suspended)): - return false - - // 4. Unknown and running moving down - case ((0, .running), (0, .running)): - break - case ((0, .running), _): - return false - case (_, (0, .running)): - return true - default: - break - } - - // Each "section" is sorted by identifier - return right.taskIdentifier < left.taskIdentifier - } - } -} - -private extension NotificationCenter { - - func publisher(for name: NSNotification.Name, filteredBy object: T) -> some Combine.Publisher { - publisher(for: name, object: nil) - .filter { notification in - object == notification.object as? T - } - } +extension MediaDownloadManager { + static let shared = MediaDownloadManager( + directoryURL: Preferences.shared.localVideoStorageURL, + engines: [URLSessionMediaDownloadEngine.self, AVAssetMediaDownloadEngine.self], + metadataStorage: FSMediaDownloadMetadataStore(directoryURL: Preferences.shared.downloadMetadataStorageURL) + ) } diff --git a/WWDC/DownloadViewModel.swift b/WWDC/DownloadViewModel.swift index 270c144d..a65b8eb7 100644 --- a/WWDC/DownloadViewModel.swift +++ b/WWDC/DownloadViewModel.swift @@ -10,11 +10,11 @@ import ConfCore import Combine final class DownloadViewModel { - let download: DownloadManager.Download - let status: AnyPublisher + let download: MediaDownload + let status: AnyPublisher let session: Session - init(download: DownloadManager.Download, status: AnyPublisher, session: Session) { + init(download: MediaDownload, status: AnyPublisher, session: Session) { self.download = download self.status = status self.session = session diff --git a/WWDC/DownloadsManagementTableCellView.swift b/WWDC/DownloadsManagementTableCellView.swift index 9ffebdc0..cc312c9b 100644 --- a/WWDC/DownloadsManagementTableCellView.swift +++ b/WWDC/DownloadsManagementTableCellView.swift @@ -17,19 +17,15 @@ final class DownloadsManagementTableCellView: NSTableCellView { return formatter }() - static func statusString(for info: DownloadManager.DownloadInfo, download: DownloadManager.Download) -> String { + static func statusString(for info: MediaDownloadState, download: MediaDownload) -> String { var status = "" - if download.state == .suspended { + if download.isPaused { status = "Paused" - } else if info.totalBytesExpectedToWrite == 0 { + } else if info == .waiting { status = "Waiting..." } else { - let formatter = DownloadsManagementTableCellView.byteCounterFormatter - - status += "\(formatter.string(fromByteCount: info.totalBytesWritten))" - status += " of " - status += "\(formatter.string(fromByteCount: info.totalBytesExpectedToWrite))" + status = "Downloading" } return status @@ -70,16 +66,11 @@ final class DownloadsManagementTableCellView: NSTableCellView { guard let self = self else { return } switch status { - case .downloading(let info), .paused(let info): - if info.totalBytesExpectedToWrite > 0 { - self.progressIndicator.isIndeterminate = false - self.progressIndicator.doubleValue = info.progress - } else { - self.progressIndicator.isIndeterminate = true - self.progressIndicator.startAnimation(nil) - } - self.downloadStatusLabel.stringValue = DownloadsManagementTableCellView.statusString(for: info, download: download) - case .finished, .cancelled, .none, .failed: () + case .downloading(let progress), .paused(let progress): + self.progressIndicator.isIndeterminate = false + self.progressIndicator.doubleValue = progress + self.downloadStatusLabel.stringValue = DownloadsManagementTableCellView.statusString(for: status, download: download) + case .completed, .cancelled, .failed, .waiting: () } } .store(in: &cancellables) @@ -154,16 +145,28 @@ final class DownloadsManagementTableCellView: NSTableCellView { @objc private func togglePause() { - if viewModel?.download.state == .suspended { - viewModel?.download.resume() - } else if viewModel?.download.state == .running { - viewModel?.download.pause() + guard let viewModel else { return } + + do { + if viewModel.download.isPaused { + try MediaDownloadManager.shared.resume(viewModel.download) + } else { + try MediaDownloadManager.shared.pause(viewModel.download) + } + } catch { + NSAlert(error: error).runModal() } } @objc private func cancel() { - viewModel?.download.cancel() + guard let viewModel else { return } + + do { + try MediaDownloadManager.shared.cancel(viewModel.download) + } catch { + NSAlert(error: error).runModal() + } } private func setup() { diff --git a/WWDC/DownloadsManagementViewController.swift b/WWDC/DownloadsManagementViewController.swift index b820f710..2e972128 100644 --- a/WWDC/DownloadsManagementViewController.swift +++ b/WWDC/DownloadsManagementViewController.swift @@ -70,11 +70,11 @@ class DownloadsManagementViewController: NSViewController { scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -Metrics.topPadding).isActive = true } - let downloadManager: DownloadManager + let downloadManager: MediaDownloadManager let storage: Storage private lazy var cancellables: Set = [] - var downloads = [DownloadManager.Download]() { + var downloads = [MediaDownload]() { didSet { if downloads.count == 0 { dismiss(nil) @@ -96,7 +96,7 @@ class DownloadsManagementViewController: NSViewController { return mainSize ?? NSSize(width: Metrics.popOverDesiredWidth, height: Metrics.popOverDesiredHeight) } - init(downloadManager: DownloadManager, storage: Storage) { + init(downloadManager: MediaDownloadManager, storage: Storage) { self.downloadManager = downloadManager self.storage = storage @@ -106,7 +106,7 @@ class DownloadsManagementViewController: NSViewController { .$downloads .throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] in - self?.downloads = $0.sorted(by: DownloadManager.Download.sortingFunction) + self?.downloads = $0.sorted(by: MediaDownload.sortingFunction) } .store(in: &cancellables) } @@ -129,7 +129,7 @@ extension DownloadsManagementViewController: NSTableViewDataSource, NSTableViewD func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { let download = downloads[row] - guard let session = storage.session(with: download.session.sessionIdentifier) else { return nil } + guard let session = storage.session(with: download.id) else { return nil } var cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: Constants.downloadStatusCellIdentifier), owner: tableView) as? DownloadsManagementTableCellView @@ -138,9 +138,7 @@ extension DownloadsManagementViewController: NSTableViewDataSource, NSTableViewD cell?.identifier = NSUserInterfaceItemIdentifier(rawValue: Constants.downloadStatusCellIdentifier) } - if let status = downloadManager.downloadStatusObservable(for: download) { - cell?.viewModel = DownloadViewModel(download: download, status: status, session: session) - } + cell?.viewModel = DownloadViewModel(download: download, status: download.$state.eraseToAnyPublisher(), session: session) return cell } diff --git a/WWDC/ExploreTabItemView.swift b/WWDC/ExploreTabItemView.swift index 062e823c..5b99d20e 100644 --- a/WWDC/ExploreTabItemView.swift +++ b/WWDC/ExploreTabItemView.swift @@ -1,5 +1,6 @@ import SwiftUI +@MainActor struct ExploreTabItemView: View { var layout: ExploreTabContent.Section.Layout var item: ExploreTabContent.Item @@ -19,6 +20,7 @@ struct ExploreTabItemView: View { .contentShape(Rectangle()) } + @MainActor private struct CardLayout: View { var item: ExploreTabContent.Item var imageHeight: CGFloat = 200 diff --git a/WWDC/ExploreTabRootView.swift b/WWDC/ExploreTabRootView.swift index 92a0a408..3e3935fd 100644 --- a/WWDC/ExploreTabRootView.swift +++ b/WWDC/ExploreTabRootView.swift @@ -1,5 +1,6 @@ import SwiftUI +@MainActor struct ExploreTabRootView: View { @EnvironmentObject private var provider: ExploreTabProvider @@ -20,6 +21,7 @@ struct ExploreTabRootView: View { } +@MainActor struct ExploreTabContentView: View { static let cardImageCornerRadius: CGFloat = 8 static let cardWidth: CGFloat = 240 @@ -94,6 +96,7 @@ struct ExploreTabContentView: View { } } + @MainActor private func open(_ item: ExploreTabContent.Item) { guard let destination = item.destination else { return diff --git a/WWDC/MediaDownload/MediaDownloadManager+.swift b/WWDC/MediaDownload/MediaDownloadManager+.swift new file mode 100644 index 00000000..4065d624 --- /dev/null +++ b/WWDC/MediaDownload/MediaDownloadManager+.swift @@ -0,0 +1,59 @@ +import SwiftUI +import ConfCore +import RealmSwift + +extension MediaDownloadManager { + @MainActor + func download(_ sessions: [Session]) { + let containers = sessions.map(\.mediaContainer) + performAction({ try await self.startDownload(for: $0) }, with: containers) + } + + @MainActor + func cancelDownload(for sessions: [Session]) { + let containers = sessions.map(\.mediaContainer) + performAction({ try self.cancelDownload(for: $0) }, with: containers) + } + + @MainActor + func pauseDownload(for sessions: [Session]) { + let containers = sessions.map(\.mediaContainer) + performAction({ try self.pauseDownload(for: $0) }, with: containers) + } + + @MainActor + func delete(_ sessions: [Session]) { + let containers = sessions.map(\.mediaContainer) + performAction({ try self.removeDownloadedMedia(for: $0) }, with: containers) + } + + private func cancelDownload(for container: SessionMediaContainer) throws { + guard let download = self.download(for: container) else { + throw "Couldn't find download for \(container.id)." + } + try self.cancel(download) + } + + private func pauseDownload(for container: SessionMediaContainer) throws { + guard let download = self.download(for: container) else { + throw "Couldn't find download for \(container.id)." + } + try self.pause(download) + } + + private func performAction(_ action: @escaping (SessionMediaContainer) async throws -> Void, with sessions: [SessionMediaContainer]) { + Task { + var alerted = false + for session in sessions { + do { + try await action(session) + } catch { + guard !alerted else { continue } + alerted = true + + await NSAlert(error: error).runModal() + } + } + } + } +} diff --git a/WWDC/MediaDownload/MediaDownloadManager.swift b/WWDC/MediaDownload/MediaDownloadManager.swift index 0c98bece..5d665eea 100644 --- a/WWDC/MediaDownload/MediaDownloadManager.swift +++ b/WWDC/MediaDownload/MediaDownloadManager.swift @@ -70,17 +70,17 @@ public final class MediaDownloadManager: ObservableObject, Logging { for variant in variants { if let url = content.remoteDownloadURL(for: variant) { guard let localPath = content.relativeLocalPath(for: variant) else { - throw "Unable to determine local path for downloading \(content.id), variant \(variant)." + throw "Unable to determine local path for downloading \(content.downloadIdentifier), variant \(variant)." } return try await _startDownload(for: content, remoteURL: url, relativeLocalPath: localPath) } } - throw "Couldn't find a downloadable variant for \(content.id)" + throw "Couldn't find a downloadable variant for \(content.downloadIdentifier)" } catch { - log.error("Start failed for \(content.id, privacy: .public): \(error, privacy: .public)") - + log.error("Start failed for \(content.downloadIdentifier, privacy: .public): \(error, privacy: .public)") + throw error } } @@ -97,7 +97,7 @@ public final class MediaDownloadManager: ObservableObject, Logging { } return nil } - + /// Checks if a given content has been downloaded. /// - Parameters: /// - content: The content to check. @@ -110,20 +110,25 @@ public final class MediaDownloadManager: ObservableObject, Logging { /// Deletes existing downloaded media for the specified content / variants. public func removeDownloadedMedia(for content: T, variants: [T.MediaVariant] = T.mediaDownloadVariants) throws { guard let fileURL = downloadedFileURL(for: content, variants: variants) else { - throw "Download doesn't exist for \(content.id)." + throw "Download doesn't exist for \(content.downloadIdentifier)." } try fileManager.removeItem(at: fileURL) } /// Returns the active download for the specified content, if any. public func download(for content: T) -> MediaDownload? { - try? _download(with: content.id) + try? _download(with: content.downloadIdentifier) + } + + /// Whether the specified content has any downloadable media. + public func canDownloadMedia(for content: T, variants: [T.MediaVariant] = T.mediaDownloadVariants) -> Bool { + variants.contains(where: { content.remoteDownloadURL(for: $0) != nil }) } /// Checks if there's an active download for the specified content. /// Returns `true` for any download state except for `.completed`. public func isDownloadingMedia(for content: T) -> Bool { - guard let download = (try? _download(with: content.id)) else { return false } + guard let download = (try? _download(with: content.downloadIdentifier)) else { return false } return download.state != .completed } @@ -285,7 +290,7 @@ private extension MediaDownloadManager { /// Creates and starts a download for the specified content and remote URL. func _startDownload(for content: T, remoteURL: URL, relativeLocalPath: String) async throws -> MediaDownload { - let id = content.id + let id = content.downloadIdentifier let download: MediaDownload var isNewDownload = false @@ -294,7 +299,7 @@ private extension MediaDownloadManager { isNewDownload = true return MediaDownload( - id: content.id, + id: id, title: content.title, remoteURL: remoteURL, relativeLocalPath: relativeLocalPath @@ -312,7 +317,7 @@ private extension MediaDownloadManager { download = createDownload() } else { guard existingDownload.state.isResumable else { - throw "A download already exists for \(content.id)." + throw "A download already exists for \(id)." } download = existingDownload @@ -482,7 +487,7 @@ private extension MediaDownloadManager { try _moveIntoPlaceIfNeeded(download, state: state) } catch { log.error("Moving into place failed for \(id, privacy: .public): \(error, privacy: .public)") - + DispatchQueue.main.async { download.state = .failed(message: error.localizedDescription) } diff --git a/WWDC/MediaDownload/MediaDownloadProtocols.swift b/WWDC/MediaDownload/MediaDownloadProtocols.swift index 6e0deddb..e922eada 100644 --- a/WWDC/MediaDownload/MediaDownloadProtocols.swift +++ b/WWDC/MediaDownload/MediaDownloadProtocols.swift @@ -4,7 +4,10 @@ import Cocoa public protocol DownloadableMediaVariant: Hashable { } /// Protocol adopted by types that have media that can be downloaded by ``MediaDownloadManager``. -public protocol DownloadableMediaContainer: Identifiable where ID == String { +public protocol DownloadableMediaContainer { + /// Identifier for downloads created for this media container. + var downloadIdentifier: String { get } + /// The type that describes the supported downlodable media variants. associatedtype MediaVariant: DownloadableMediaVariant diff --git a/WWDC/MediaDownload/Support/MediaDownload+Sorting.swift b/WWDC/MediaDownload/Support/MediaDownload+Sorting.swift new file mode 100644 index 00000000..988c9e4a --- /dev/null +++ b/WWDC/MediaDownload/Support/MediaDownload+Sorting.swift @@ -0,0 +1,25 @@ +import Foundation + +extension MediaDownload { + static func sortingFunction(lhs: MediaDownload, rhs: MediaDownload) -> Bool { + switch (lhs.state, rhs.state) { + case (.paused, .paused): + break + case (.paused, _): + return true + case (_, .paused): + return false + case (.downloading, .downloading): + break + case (.downloading, _): + return false + case (_, .downloading): + return true + default: + break + } + + /// Each "section" is sorted by identifier + return rhs.id < lhs.id + } +} diff --git a/WWDC/MediaDownload/Support/PreviewAndTestingSupport.swift b/WWDC/MediaDownload/Support/PreviewAndTestingSupport.swift index ec2132ce..09613bbd 100644 --- a/WWDC/MediaDownload/Support/PreviewAndTestingSupport.swift +++ b/WWDC/MediaDownload/Support/PreviewAndTestingSupport.swift @@ -7,6 +7,8 @@ extension URL { } public struct PreviewMediaContainer: DownloadableMediaContainer { + public var downloadIdentifier: String { id } + public static let mediaDownloadVariants = [MediaVariant.preview] public enum MediaVariant: String, DownloadableMediaVariant { diff --git a/WWDC/MediaDownload/Support/Session+Download.swift b/WWDC/MediaDownload/Support/Session+Download.swift new file mode 100644 index 00000000..2a5c0073 --- /dev/null +++ b/WWDC/MediaDownload/Support/Session+Download.swift @@ -0,0 +1,64 @@ +import Foundation +import ConfCore + +extension SessionAssetType: DownloadableMediaVariant { } + +private extension Session { + func asset(for variant: SessionAssetType) -> SessionAsset? { assets(matching: [variant]).first } +} + +extension Session { + var mediaContainer: SessionMediaContainer { SessionMediaContainer(session: self) } +} + +struct SessionMediaContainer: DownloadableMediaContainer { + struct AssetStub { + var relativeLocalPath: String + var remoteURL: URL + } + + typealias MediaVariant = SessionAssetType + + private(set) var id: String + private(set) var title: String + private(set) var assets: [MediaVariant: AssetStub] + + init(session: Session) { + self.id = session.identifier + self.assets = [:] + self.title = session.title + + if let hdVideo = session.asset(for: .hdVideo), + let remoteURL = URL(string: hdVideo.remoteURL) + { + assets[.hdVideo] = AssetStub(relativeLocalPath: hdVideo.relativeLocalURL, remoteURL: remoteURL) + } + if let hlsVideo = session.asset(for: .downloadHLSVideo), + let remoteURL = URL(string: hlsVideo.remoteURL) + { + assets[.downloadHLSVideo] = AssetStub(relativeLocalPath: hlsVideo.relativeLocalURL, remoteURL: remoteURL) + } + } + + public var downloadIdentifier: String { id } + + public static var mediaDownloadVariants: [MediaVariant] { + Preferences.shared.preferHLSVideoDownload ? [.downloadHLSVideo, .hdVideo] : [.hdVideo, .downloadHLSVideo] + } + + public func relativeLocalPath(for variant: MediaVariant) -> String? { assets[variant]?.relativeLocalPath } + + public func remoteDownloadURL(for variant: MediaVariant) -> URL? { assets[variant]?.remoteURL } +} + +extension Session: DownloadableMediaContainer { + public typealias MediaVariant = SessionAssetType + + public var downloadIdentifier: String { mediaContainer.downloadIdentifier } + + public static var mediaDownloadVariants: [SessionAssetType] { SessionMediaContainer.mediaDownloadVariants } + + public func relativeLocalPath(for variant: SessionAssetType) -> String? { mediaContainer.relativeLocalPath(for: variant) } + + public func remoteDownloadURL(for variant: SessionAssetType) -> URL? { mediaContainer.remoteDownloadURL(for: variant) } +} diff --git a/WWDC/PlaybackViewModel.swift b/WWDC/PlaybackViewModel.swift index 1328bde2..568cc84d 100644 --- a/WWDC/PlaybackViewModel.swift +++ b/WWDC/PlaybackViewModel.swift @@ -88,7 +88,7 @@ final class PlaybackViewModel { remoteMediaURL = remoteUrl // check if we have a downloaded file and use it instead - if let localUrl = DownloadManager.shared.downloadedFileURL(for: session) { + if let localUrl = MediaDownloadManager.shared.downloadedFileURL(for: session) { streamUrl = localUrl } else { streamUrl = remoteUrl diff --git a/WWDC/Preferences.swift b/WWDC/Preferences.swift index ab69b55a..505cfc32 100644 --- a/WWDC/Preferences.swift +++ b/WWDC/Preferences.swift @@ -28,7 +28,8 @@ final class Preferences { init() { defaults.register(defaults: [ "localVideoStoragePath": Self.defaultLocalVideoStoragePath, - "includeAppBannerInSharedClips": true + "includeAppBannerInSharedClips": true, + "preferHLSVideoDownload": true ]) } @@ -38,6 +39,28 @@ final class Preferences { set { localVideoStoragePath = newValue.path } } + /// Prioritizes downloading the HLS version of the video if available. + /// The default is `true`. When `false`, downloads the HD variant (mp4) instead. + var preferHLSVideoDownload: Bool { + get { defaults.bool(forKey: #function) } + set { defaults.set(newValue, forKey: #function) } + } + + /// Directory where in-flight download metadata is kept. + var downloadMetadataStorageURL: URL { + let baseURL = URL(fileURLWithPath: Self.defaultLocalVideoStoragePath) + let dirURL = baseURL.appendingPathComponent(".DownloadMetadata") + if !FileManager.default.fileExists(atPath: dirURL.path) { + do { + try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) + } catch { + assertionFailure("Error creating download metadata storage directory: \(error)") + return URL(fileURLWithPath: NSTemporaryDirectory()) + } + } + return dirURL + } + private var localVideoStoragePath: String { get { if let path = defaults.object(forKey: #function) as? String { diff --git a/WWDC/SessionActionsViewController.swift b/WWDC/SessionActionsViewController.swift index 8b4bea04..aaa83389 100644 --- a/WWDC/SessionActionsViewController.swift +++ b/WWDC/SessionActionsViewController.swift @@ -184,36 +184,41 @@ class SessionActionsViewController: NSViewController { } .store(in: &cancellables) - if let downloadState = DownloadManager.shared.downloadStatusObservable(for: viewModel.session) { + if let downloadState = MediaDownloadManager.shared.download(for: viewModel.session)?.$state { downloadState .throttle(for: .milliseconds(800), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] status in switch status { - case .downloading(let info): + case .waiting: + self?.downloadIndicator.isHidden = false + self?.downloadButton.isHidden = true + self?.clipButton.isHidden = true + self?.downloadIndicator.isIndeterminate = true + self?.downloadIndicator.startAnimating() + case .downloading(let progress): self?.downloadIndicator.isHidden = false self?.downloadButton.isHidden = true self?.clipButton.isHidden = true - if info.progress < 0 { + if progress < 0 { self?.downloadIndicator.isIndeterminate = true self?.downloadIndicator.startAnimating() } else { self?.downloadIndicator.isIndeterminate = false - self?.downloadIndicator.progress = Float(info.progress) + self?.downloadIndicator.progress = Float(progress) } - case .failed: let alert = WWDCAlert.create() alert.messageText = "Download Failed!" alert.informativeText = "An error occurred while attempting to download \"\(viewModel.title)\"." alert.runModal() fallthrough - case .paused, .cancelled, .none: + case .paused, .cancelled: self?.resetDownloadButton() self?.downloadIndicator.isHidden = true self?.downloadButton.isHidden = false self?.clipButton.isHidden = true - case .finished: + case .completed: self?.downloadButton.toolTip = "Delete downloaded video" self?.downloadButton.isHidden = false self?.downloadIndicator.isHidden = true diff --git a/WWDC/SessionViewModel.swift b/WWDC/SessionViewModel.swift index a38f8b66..86a23730 100644 --- a/WWDC/SessionViewModel.swift +++ b/WWDC/SessionViewModel.swift @@ -145,12 +145,6 @@ final class SessionViewModel { return validAssetsObservable.map { $0.count > 0 } }() - lazy var rxDownloadableContent: some Publisher, Error> = { - let downloadableAssets = self.session.assets.filter("(rawAssetType == %@ AND remoteURL != '')", DownloadManager.downloadQuality.rawValue) - - return downloadableAssets.collectionPublisher - }() - lazy var rxProgresses: some Publisher, Error> = { let progresses = self.session.progresses.filter(NSPredicate(value: true)) diff --git a/WWDC/SessionsTableViewController.swift b/WWDC/SessionsTableViewController.swift index 1047a0c7..881e7f7a 100644 --- a/WWDC/SessionsTableViewController.swift +++ b/WWDC/SessionsTableViewController.swift @@ -494,15 +494,15 @@ class SessionsTableViewController: NSViewController, NSMenuItemValidation, Loggi switch menuItem.option { case .download: - return DownloadManager.shared.isDownloadable(viewModel.session) && - !DownloadManager.shared.isDownloading(viewModel.session) && - !DownloadManager.shared.hasDownloadedVideo(session: viewModel.session) + return MediaDownloadManager.shared.canDownloadMedia(for: viewModel.session) && + !MediaDownloadManager.shared.isDownloadingMedia(for: viewModel.session) && + !MediaDownloadManager.shared.hasDownloadedMedia(for: viewModel.session) case .removeDownload: return viewModel.session.isDownloaded case .cancelDownload: - return DownloadManager.shared.isDownloadable(viewModel.session) && DownloadManager.shared.isDownloading(viewModel.session) + return MediaDownloadManager.shared.canDownloadMedia(for: viewModel.session) && MediaDownloadManager.shared.isDownloadingMedia(for: viewModel.session) case .revealInFinder: - return DownloadManager.shared.hasDownloadedVideo(session: viewModel.session) + return MediaDownloadManager.shared.hasDownloadedMedia(for: viewModel.session) default: () } diff --git a/WWDC/ShelfViewController.swift b/WWDC/ShelfViewController.swift index 399cfe65..de6fa36a 100644 --- a/WWDC/ShelfViewController.swift +++ b/WWDC/ShelfViewController.swift @@ -146,7 +146,7 @@ final class ShelfViewController: NSViewController, PUIPlayerViewDetachedStatusPr func showClipUI() { guard let session = viewModel?.session else { return } - guard let url = DownloadManager.shared.downloadedFileURL(for: session) else { return } + guard let url = MediaDownloadManager.shared.downloadedFileURL(for: session) else { return } let subtitle = session.event.first?.name ?? "Apple Developer" diff --git a/WWDC/TitleBarButtonsViewController.swift b/WWDC/TitleBarButtonsViewController.swift index db260389..b6a5adf1 100644 --- a/WWDC/TitleBarButtonsViewController.swift +++ b/WWDC/TitleBarButtonsViewController.swift @@ -12,7 +12,7 @@ import Combine import SwiftUI final class TitleBarButtonsViewController: NSViewController { - private let downloadManager: DownloadManager + private let downloadManager: MediaDownloadManager private let storage: Storage private weak var managementViewController: DownloadsManagementViewController? @@ -22,7 +22,7 @@ final class TitleBarButtonsViewController: NSViewController { private lazy var cancellables = Set() - init(downloadManager: DownloadManager, storage: Storage) { + init(downloadManager: MediaDownloadManager, storage: Storage) { self.downloadManager = downloadManager self.storage = storage From 33bf8f8047fdf5ad1b216f67ed77e3324f1cca8a Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Mon, 3 Jun 2024 19:41:11 -0300 Subject: [PATCH 03/12] WIP --- WWDC.xcodeproj/project.pbxproj | 14 +- WWDC/AppCoordinator.swift | 7 +- .../DownloadedContentMonitor.swift | 130 ++++++++++++++++++ .../Session+Download.swift | 2 +- 4 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 WWDC/MediaDownload/Integration/DownloadedContentMonitor.swift rename WWDC/MediaDownload/{Support => Integration}/Session+Download.swift (98%) diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index bb6235be..350be798 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -186,6 +186,7 @@ F46E0AE02C0E6CB20077A5E0 /* MediaDownload+Sorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46E0ADF2C0E6CB20077A5E0 /* MediaDownload+Sorting.swift */; }; F46E0AE22C0E6DA80077A5E0 /* Session+Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46E0AE12C0E6DA80077A5E0 /* Session+Download.swift */; }; F46E0AE42C0E6EF80077A5E0 /* MediaDownloadManager+.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46E0AE32C0E6EF80077A5E0 /* MediaDownloadManager+.swift */; }; + F46E0AE72C0E7B780077A5E0 /* DownloadedContentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46E0AE62C0E7B780077A5E0 /* DownloadedContentMonitor.swift */; }; F474DEC926737EFA00B28B31 /* SharePlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474DEC826737EFA00B28B31 /* SharePlayManager.swift */; }; F474DECD2673801500B28B31 /* WatchWWDCActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474DECC2673801500B28B31 /* WatchWWDCActivity.swift */; }; F4777ABA2A2A2F6C00A09179 /* WWDCAgentRemover.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4777AB92A2A2F6C00A09179 /* WWDCAgentRemover.swift */; }; @@ -473,6 +474,7 @@ F46E0ADF2C0E6CB20077A5E0 /* MediaDownload+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaDownload+Sorting.swift"; sourceTree = ""; }; F46E0AE12C0E6DA80077A5E0 /* Session+Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Session+Download.swift"; sourceTree = ""; }; F46E0AE32C0E6EF80077A5E0 /* MediaDownloadManager+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaDownloadManager+.swift"; sourceTree = ""; }; + F46E0AE62C0E7B780077A5E0 /* DownloadedContentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedContentMonitor.swift; sourceTree = ""; }; F474DEC826737EFA00B28B31 /* SharePlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePlayManager.swift; sourceTree = ""; }; F474DECC2673801500B28B31 /* WatchWWDCActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchWWDCActivity.swift; sourceTree = ""; }; F4777AB92A2A2F6C00A09179 /* WWDCAgentRemover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WWDCAgentRemover.swift; sourceTree = ""; }; @@ -1114,6 +1116,15 @@ name = Models; sourceTree = ""; }; + F46E0AE52C0E7B6B0077A5E0 /* Integration */ = { + isa = PBXGroup; + children = ( + F46E0AE12C0E6DA80077A5E0 /* Session+Download.swift */, + F46E0AE62C0E7B780077A5E0 /* DownloadedContentMonitor.swift */, + ); + path = Integration; + sourceTree = ""; + }; F474DECA2673800000B28B31 /* SharePlay */ = { isa = PBXGroup; children = ( @@ -1134,6 +1145,7 @@ F486B2F72C0E69A60066749F /* MediaDownload */ = { isa = PBXGroup; children = ( + F46E0AE52C0E7B6B0077A5E0 /* Integration */, F486B3052C0E69E60066749F /* Support */, F486B2FE2C0E69E60066749F /* Engines */, F486B2F92C0E69E60066749F /* FSMediaDownloadMetadataStore.swift */, @@ -1164,7 +1176,6 @@ F486B3032C0E69E60066749F /* URL+FileHelpers.swift */, F486B3042C0E69E60066749F /* URLSessionTask+Media.swift */, F46E0ADF2C0E6CB20077A5E0 /* MediaDownload+Sorting.swift */, - F46E0AE12C0E6DA80077A5E0 /* Session+Download.swift */, ); path = Support; sourceTree = ""; @@ -1501,6 +1512,7 @@ F486B3122C0E69E60066749F /* URLSessionTask+Media.swift in Sources */, DDF32EB31EBE5C4D0028E39D /* SessionActionsViewController.swift in Sources */, DDA7B7352484416B00F86668 /* CompositionalLayoutBackgroundSwizzler.m in Sources */, + F46E0AE72C0E7B780077A5E0 /* DownloadedContentMonitor.swift in Sources */, F4578D5B2A2659F0005B311A /* VideoPlayer.swift in Sources */, 4D4D80C4217D281D00D1C233 /* DownloadViewModel.swift in Sources */, DDB28F931EAD48D70077703F /* UserActivityRepresentable.swift in Sources */, diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index c3fd0d9d..7d5ba8c8 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -96,6 +96,8 @@ final class AppCoordinator: Logging, Signposting { } } + private lazy var downloadMonitor = DownloadedContentMonitor() + @MainActor init(windowController: MainWindowController, storage: Storage, syncEngine: SyncEngine) { let signpostState = Self.signposter.beginInterval("initialization", id: Self.signposter.makeSignpostID(), "begin init") @@ -113,8 +115,6 @@ final class AppCoordinator: Logging, Signposting { ) self.searchCoordinator = searchCoordinator - MediaDownloadManager.shared.activate() - liveObserver = LiveObserver(dateProvider: today, storage: storage, syncEngine: syncEngine) // Primary UI Initialization @@ -188,6 +188,9 @@ final class AppCoordinator: Logging, Signposting { DispatchQueue.main.async { self?.startSharePlay() } } + MediaDownloadManager.shared.activate() + downloadMonitor.activate(with: storage) + startup() Self.signposter.endInterval("initialization", signpostState, "end init") } diff --git a/WWDC/MediaDownload/Integration/DownloadedContentMonitor.swift b/WWDC/MediaDownload/Integration/DownloadedContentMonitor.swift new file mode 100644 index 00000000..ac3f0b72 --- /dev/null +++ b/WWDC/MediaDownload/Integration/DownloadedContentMonitor.swift @@ -0,0 +1,130 @@ +import Cocoa +import ConfCore + +final class DownloadedContentMonitor { + var storage: Storage? + + @MainActor + func activate(with storage: Storage?) { + self.storage = storage + + _ = NotificationCenter.default.addObserver(forName: .LocalVideoStoragePathPreferenceDidChange, object: nil, queue: nil) { _ in + self.monitorDownloadsFolder() + } + + updateDownloadedFlagsOfPreviouslyDownloaded() + monitorDownloadsFolder() + } + + fileprivate var topFolderMonitor: DTFolderMonitor! + fileprivate var subfoldersMonitors: [DTFolderMonitor] = [] + fileprivate var existingVideoFiles = [String]() + + func syncWithFileSystem() { + let videosPath = Preferences.shared.localVideoStorageURL.path + updateDownloadedFlagsByEnumeratingFilesAtPath(videosPath) + } + + private func monitorDownloadsFolder() { + if topFolderMonitor != nil { + topFolderMonitor.stopMonitoring() + topFolderMonitor = nil + } + + subfoldersMonitors.forEach({ $0.stopMonitoring() }) + subfoldersMonitors.removeAll() + + let url = Preferences.shared.localVideoStorageURL + + topFolderMonitor = DTFolderMonitor(for: url) { [unowned self] in + self.setupSubdirectoryMonitors(on: url) + + self.updateDownloadedFlagsByEnumeratingFilesAtPath(url.path) + } + + setupSubdirectoryMonitors(on: url) + + topFolderMonitor.startMonitoring() + } + + private func setupSubdirectoryMonitors(on mainDirURL: URL) { + subfoldersMonitors.forEach({ $0.stopMonitoring() }) + subfoldersMonitors.removeAll() + + mainDirURL.subDirectories.forEach { subdir in + guard let monitor = DTFolderMonitor(for: subdir, block: { [unowned self] in + self.updateDownloadedFlagsByEnumeratingFilesAtPath(mainDirURL.path) + }) else { return } + + subfoldersMonitors.append(monitor) + + monitor.startMonitoring() + } + } + + fileprivate func updateDownloadedFlagsOfPreviouslyDownloaded() { + guard let storage else { return } + + let expectedOnDisk = storage.sessions.filter(NSPredicate(format: "isDownloaded == true")) + var notPresent = [String]() + + for session in expectedOnDisk { + if !MediaDownloadManager.shared.hasDownloadedMedia(for: session) { + Session.mediaDownloadVariants.forEach { + guard let asset = session.asset(for: $0) else { return } + + notPresent.append(asset.relativeLocalURL) + } + } + } + + storage.updateDownloadedFlag(false, forAssetsAtPaths: notPresent) + notPresent.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } + } + + /// Updates the downloaded status for the sessions on the database based on the existence of the downloaded video file + /// + /// This function is only ever called with the main destination directory, despite what the rest + /// of the architecture might suggest. The subfolder monitors just force the entire hierarchy to be + /// re-enumerated. This function has signifcant side effects. + fileprivate func updateDownloadedFlagsByEnumeratingFilesAtPath(_ path: String) { + guard let storage else { return } + + guard let enumerator = FileManager.default.enumerator(atPath: path) else { return } + + var files: [String] = [] + + while let path = enumerator.nextObject() as? String { + if enumerator.level > 1 { enumerator.skipDescendants() } + files.append(path) + } + + guard !files.isEmpty else { return } + + storage.updateDownloadedFlag(true, forAssetsAtPaths: files) + + files.forEach { NotificationCenter.default.post(name: .DownloadManagerFileAddedNotification, object: $0) } + + if existingVideoFiles.count == 0 { + existingVideoFiles = files + return + } + + let removedFiles = existingVideoFiles.filter { !files.contains($0) } + + storage.updateDownloadedFlag(false, forAssetsAtPaths: removedFiles) + + removedFiles.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } + + // This is now the list of files + existingVideoFiles = files + } + + // MARK: Teardown + + deinit { + if topFolderMonitor != nil { + topFolderMonitor.stopMonitoring() + } + } +} diff --git a/WWDC/MediaDownload/Support/Session+Download.swift b/WWDC/MediaDownload/Integration/Session+Download.swift similarity index 98% rename from WWDC/MediaDownload/Support/Session+Download.swift rename to WWDC/MediaDownload/Integration/Session+Download.swift index 2a5c0073..5b46a8a5 100644 --- a/WWDC/MediaDownload/Support/Session+Download.swift +++ b/WWDC/MediaDownload/Integration/Session+Download.swift @@ -3,7 +3,7 @@ import ConfCore extension SessionAssetType: DownloadableMediaVariant { } -private extension Session { +extension Session { func asset(for variant: SessionAssetType) -> SessionAsset? { assets(matching: [variant]).first } } From eac08b0e591f42287eb957bc99ff75d5d6f3d329 Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 10:30:54 -0300 Subject: [PATCH 04/12] Fixed downloads filesystem sync with new engine --- WWDC/AppCoordinator.swift | 8 +- .../DownloadedContentMonitor.swift | 184 +++++++++++------- 2 files changed, 117 insertions(+), 75 deletions(-) diff --git a/WWDC/AppCoordinator.swift b/WWDC/AppCoordinator.swift index 7d5ba8c8..9acc3c2f 100644 --- a/WWDC/AppCoordinator.swift +++ b/WWDC/AppCoordinator.swift @@ -212,9 +212,11 @@ final class AppCoordinator: Logging, Signposting { self.preferredTranscriptLanguageDidChange($0) }.store(in: &cancellables) NotificationCenter.default.publisher(for: .SyncEngineDidSyncSessionsAndSchedule).receive(on: DispatchQueue.main).sink { [weak self] note in - guard self?.checkSyncEngineOperationSucceededAndShowError(note: note) == true else { return } - #warning("TODO: Reimplement with new download manager (or remove)") -// DownloadManager.shared.syncWithFileSystem() + guard let self else { return } + + guard self.checkSyncEngineOperationSucceededAndShowError(note: note) == true else { return } + + self.downloadMonitor.syncWithFileSystem() }.store(in: &cancellables) NotificationCenter.default.publisher(for: .WWDCEnvironmentDidChange).receive(on: DispatchQueue.main).sink { _ in self.refresh(nil) diff --git a/WWDC/MediaDownload/Integration/DownloadedContentMonitor.swift b/WWDC/MediaDownload/Integration/DownloadedContentMonitor.swift index ac3f0b72..c22c07e3 100644 --- a/WWDC/MediaDownload/Integration/DownloadedContentMonitor.swift +++ b/WWDC/MediaDownload/Integration/DownloadedContentMonitor.swift @@ -1,13 +1,18 @@ import Cocoa import ConfCore +import OSLog + +final class DownloadedContentMonitor: Logging { + static let log = makeLogger() -final class DownloadedContentMonitor { var storage: Storage? @MainActor func activate(with storage: Storage?) { + log.debug(#function) + self.storage = storage - + _ = NotificationCenter.default.addObserver(forName: .LocalVideoStoragePathPreferenceDidChange, object: nil, queue: nil) { _ in self.monitorDownloadsFolder() } @@ -17,114 +22,149 @@ final class DownloadedContentMonitor { } fileprivate var topFolderMonitor: DTFolderMonitor! - fileprivate var subfoldersMonitors: [DTFolderMonitor] = [] - fileprivate var existingVideoFiles = [String]() + fileprivate var subfoldersMonitors: [DTFolderMonitor] = [] + fileprivate var existingVideoFiles = [String]() + + func syncWithFileSystem() { + log.debug(#function) + + let videosPath = Preferences.shared.localVideoStorageURL.path + updateDownloadedFlagsByEnumeratingFilesAtPath(videosPath) + } - func syncWithFileSystem() { - let videosPath = Preferences.shared.localVideoStorageURL.path - updateDownloadedFlagsByEnumeratingFilesAtPath(videosPath) + private func monitorDownloadsFolder() { + if topFolderMonitor != nil { + topFolderMonitor.stopMonitoring() + topFolderMonitor = nil } - private func monitorDownloadsFolder() { - if topFolderMonitor != nil { - topFolderMonitor.stopMonitoring() - topFolderMonitor = nil - } + subfoldersMonitors.forEach({ $0.stopMonitoring() }) + subfoldersMonitors.removeAll() - subfoldersMonitors.forEach({ $0.stopMonitoring() }) - subfoldersMonitors.removeAll() + let url = Preferences.shared.localVideoStorageURL - let url = Preferences.shared.localVideoStorageURL + topFolderMonitor = DTFolderMonitor(for: url) { [unowned self] in + self.setupSubdirectoryMonitors(on: url) - topFolderMonitor = DTFolderMonitor(for: url) { [unowned self] in - self.setupSubdirectoryMonitors(on: url) + self.updateDownloadedFlagsByEnumeratingFilesAtPath(url.path) + } - self.updateDownloadedFlagsByEnumeratingFilesAtPath(url.path) - } + setupSubdirectoryMonitors(on: url) - setupSubdirectoryMonitors(on: url) + topFolderMonitor.startMonitoring() + } - topFolderMonitor.startMonitoring() - } + private func setupSubdirectoryMonitors(on mainDirURL: URL) { + subfoldersMonitors.forEach({ $0.stopMonitoring() }) + subfoldersMonitors.removeAll() - private func setupSubdirectoryMonitors(on mainDirURL: URL) { - subfoldersMonitors.forEach({ $0.stopMonitoring() }) - subfoldersMonitors.removeAll() + mainDirURL.subDirectories.forEach { subdir in + guard let monitor = DTFolderMonitor(for: subdir, block: { [unowned self] in + self.updateDownloadedFlagsByEnumeratingFilesAtPath(mainDirURL.path) + }) else { return } - mainDirURL.subDirectories.forEach { subdir in - guard let monitor = DTFolderMonitor(for: subdir, block: { [unowned self] in - self.updateDownloadedFlagsByEnumeratingFilesAtPath(mainDirURL.path) - }) else { return } + subfoldersMonitors.append(monitor) - subfoldersMonitors.append(monitor) + monitor.startMonitoring() + } + } - monitor.startMonitoring() - } + fileprivate func updateDownloadedFlagsOfPreviouslyDownloaded() { + guard let storage else { + log.warning("Asked to update downloaded flags without the storage being available") + return } - fileprivate func updateDownloadedFlagsOfPreviouslyDownloaded() { - guard let storage else { return } + let expectedOnDisk = storage.sessions.filter(NSPredicate(format: "isDownloaded == true")) + var notPresent = [String]() - let expectedOnDisk = storage.sessions.filter(NSPredicate(format: "isDownloaded == true")) - var notPresent = [String]() + for session in expectedOnDisk { + if !MediaDownloadManager.shared.hasDownloadedMedia(for: session) { + Session.mediaDownloadVariants.forEach { + guard let asset = session.asset(for: $0) else { return } - for session in expectedOnDisk { - if !MediaDownloadManager.shared.hasDownloadedMedia(for: session) { - Session.mediaDownloadVariants.forEach { - guard let asset = session.asset(for: $0) else { return } - - notPresent.append(asset.relativeLocalURL) - } + notPresent.append(asset.relativeLocalURL) } } + } + + if !notPresent.isEmpty { + log.info("Found \(notPresent.count, privacy: .public) media files which had the downloaded flag, but are no longer present") storage.updateDownloadedFlag(false, forAssetsAtPaths: notPresent) + notPresent.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } } + } + + /// Updates the downloaded status for the sessions on the database based on the existence of the downloaded video file + /// + /// This function is only ever called with the main destination directory, despite what the rest + /// of the architecture might suggest. The subfolder monitors just force the entire hierarchy to be + /// re-enumerated. This function has signifcant side effects. + fileprivate func updateDownloadedFlagsByEnumeratingFilesAtPath(_ rootPath: String) { + guard let storage else { + log.warning("Asked to update downloaded flags without the storage being available") + return + } - /// Updates the downloaded status for the sessions on the database based on the existence of the downloaded video file - /// - /// This function is only ever called with the main destination directory, despite what the rest - /// of the architecture might suggest. The subfolder monitors just force the entire hierarchy to be - /// re-enumerated. This function has signifcant side effects. - fileprivate func updateDownloadedFlagsByEnumeratingFilesAtPath(_ path: String) { - guard let storage else { return } + let rootURL = URL(fileURLWithPath: rootPath) - guard let enumerator = FileManager.default.enumerator(atPath: path) else { return } + guard let enumerator = FileManager.default.enumerator(at: rootURL, includingPropertiesForKeys: nil, options: [.skipsPackageDescendants, .skipsHiddenFiles]) else { + log.error("Failed to create file enumerator at \(rootPath, privacy: .public)") + return + } - var files: [String] = [] + var files: [String] = [] - while let path = enumerator.nextObject() as? String { - if enumerator.level > 1 { enumerator.skipDescendants() } - files.append(path) - } + while let url = enumerator.nextObject() as? URL { + let path = url.path - guard !files.isEmpty else { return } + if enumerator.level > 2 { enumerator.skipDescendants() } - storage.updateDownloadedFlag(true, forAssetsAtPaths: files) + /// Special handling for HLS downloads, which are a movpkg bundle. + /// `.skipsPackageDescendants` should take care of this, but just in case... + guard !url.deletingLastPathComponent().lastPathComponent.hasSuffix("movpkg") else { continue } - files.forEach { NotificationCenter.default.post(name: .DownloadManagerFileAddedNotification, object: $0) } + /// In order to match a downloaded file with the corresponding asset, we only care about the last two path components, + /// which will compose to something like `2023/wwdc2023-10042_hd.mp4`. The URL above has the full path, this takes care of it. + let relativePath = url.pathComponents.suffix(2).joined(separator: "/") - if existingVideoFiles.count == 0 { - existingVideoFiles = files - return - } + files.append(relativePath) + } - let removedFiles = existingVideoFiles.filter { !files.contains($0) } + guard !files.isEmpty else { return } - storage.updateDownloadedFlag(false, forAssetsAtPaths: removedFiles) + log.info("Found \(files.count, privacy: .public) downloaded files") - removedFiles.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } + storage.updateDownloadedFlag(true, forAssetsAtPaths: files) + + files.forEach { NotificationCenter.default.post(name: .DownloadManagerFileAddedNotification, object: $0) } - // This is now the list of files + if existingVideoFiles.count == 0 { existingVideoFiles = files + return } - // MARK: Teardown + let removedFiles = existingVideoFiles.filter { !files.contains($0) } - deinit { - if topFolderMonitor != nil { - topFolderMonitor.stopMonitoring() - } + if !removedFiles.isEmpty { + log.info("Found \(removedFiles.count, privacy: .public) removed downloads") + + storage.updateDownloadedFlag(false, forAssetsAtPaths: removedFiles) + + removedFiles.forEach { NotificationCenter.default.post(name: .DownloadManagerFileDeletedNotification, object: $0) } } + + // This is now the list of files + existingVideoFiles = files + } + + // MARK: Teardown + + deinit { + if topFolderMonitor != nil { + topFolderMonitor.stopMonitoring() + } + } } From 420c6cd979e8d4720515503e7f9a4ea479e8003f Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 10:40:41 -0300 Subject: [PATCH 05/12] Improved clear data scripts' --- cleardata.sh | 8 +++++++- cleardebugdata.sh | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100755 cleardebugdata.sh diff --git a/cleardata.sh b/cleardata.sh index 78bafe36..ab7e68fd 100755 --- a/cleardata.sh +++ b/cleardata.sh @@ -1,4 +1,10 @@ #!/bin/bash +echo "" +echo "WARNING: this will remove all local data for both release and debug configurations, and reset all preferences" +echo "" +echo "Press any key to continue, Ctrl+C to cancel..." +read + rm -Rfv ~/Library/Application\ Support/io.wwdc.app* -defaults delete io.wwdc.app +defaults delete io.wwdc.app 2>/dev/null diff --git a/cleardebugdata.sh b/cleardebugdata.sh new file mode 100755 index 00000000..a39d3b54 --- /dev/null +++ b/cleardebugdata.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +echo "" + +DEBUG_FOLDER_PATH="$HOME/Library/Application Support/io.wwdc.app.debug" + +if [ ! -d "$DEBUG_FOLDER_PATH" ]; then + echo "Debug data folder doesn't exist at $DEBUG_FOLDER_PATH" + echo "Nothing to be done, all good!" + echo "" + exit 0 +fi + +echo "Removing DEBUG data folder at $DEBUG_FOLDER_PATH" + +rm -R "$DEBUG_FOLDER_PATH" || { echo "Failed to remove :("; exit 1; } + +echo "All good!" +echo "" \ No newline at end of file From fa073dc6f0255d6e37f503cb385c0f52f7d8e2c9 Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 13:23:31 -0300 Subject: [PATCH 06/12] Hooked up session download button to new engine --- WWDC/SessionActionsViewController.swift | 145 ++++++++++++++++-------- 1 file changed, 100 insertions(+), 45 deletions(-) diff --git a/WWDC/SessionActionsViewController.swift b/WWDC/SessionActionsViewController.swift index aaa83389..283d1a03 100644 --- a/WWDC/SessionActionsViewController.swift +++ b/WWDC/SessionActionsViewController.swift @@ -165,6 +165,8 @@ class SessionActionsViewController: NSViewController { updateBindings() } + private typealias DownloadButtonConfig = (downloadable: Bool, state: MediaDownloadState?) + private func updateBindings() { cancellables = [] @@ -184,55 +186,93 @@ class SessionActionsViewController: NSViewController { } .store(in: &cancellables) - if let downloadState = MediaDownloadManager.shared.download(for: viewModel.session)?.$state { - downloadState - .throttle(for: .milliseconds(800), scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] status in - switch status { - case .waiting: - self?.downloadIndicator.isHidden = false - self?.downloadButton.isHidden = true - self?.clipButton.isHidden = true - self?.downloadIndicator.isIndeterminate = true - self?.downloadIndicator.startAnimating() - case .downloading(let progress): - self?.downloadIndicator.isHidden = false - self?.downloadButton.isHidden = true - self?.clipButton.isHidden = true - - if progress < 0 { - self?.downloadIndicator.isIndeterminate = true - self?.downloadIndicator.startAnimating() - } else { - self?.downloadIndicator.isIndeterminate = false - self?.downloadIndicator.progress = Float(progress) - } - case .failed: - let alert = WWDCAlert.create() - alert.messageText = "Download Failed!" - alert.informativeText = "An error occurred while attempting to download \"\(viewModel.title)\"." - alert.runModal() - fallthrough - case .paused, .cancelled: - self?.resetDownloadButton() - self?.downloadIndicator.isHidden = true - self?.downloadButton.isHidden = false - self?.clipButton.isHidden = true - case .completed: - self?.downloadButton.toolTip = "Delete downloaded video" - self?.downloadButton.isHidden = false - self?.downloadIndicator.isHidden = true - self?.downloadButton.image = #imageLiteral(resourceName: "trash") - self?.downloadButton.action = #selector(SessionActionsViewController.deleteDownload) - self?.clipButton.isHidden = false + let downloadID = viewModel.session.downloadIdentifier + + /// Download state for existing in-flight download, or `nil` if there's no in-flight download for the session. + let inFlightDownloadSignal = MediaDownloadManager.shared.$downloads.map { $0.first(where: { $0.id == downloadID }) } + .eraseToAnyPublisher() + .replaceErrorWithEmpty() + + /// `true` if the session has already been downloaded. + let alreadyDownloaded = viewModel.session + .valuePublisher(keyPaths: ["isDownloaded"]) + .replaceErrorWithEmpty() + + /// This publisher includes a flag indicating whether the session can be downloaded, as well as the current download state, if any. + let downloadButtonConfig: AnyPublisher = Publishers.CombineLatest(inFlightDownloadSignal, alreadyDownloaded) + .map { inFlightDownload, session in + if session.isDownloaded { + return (true, MediaDownloadState.completed) + } else { + guard !session.assets(matching: Session.mediaDownloadVariants).isEmpty else { return (false, nil) } + if let inFlightDownload { + return (true, inFlightDownload.state) + } else { + return (true, nil) } - }.store(in: &cancellables) - } else { - // session can't be downloaded (maybe Lab or download not available yet) - downloadIndicator.isHidden = true + } + } + .eraseToAnyPublisher() + + downloadButtonConfig + .throttle(for: .milliseconds(800), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] config in + self?.configureDownloadButton(with: config) + } + .store(in: &cancellables) + } + + private func configureDownloadButton(with config: DownloadButtonConfig) { + /// Ignore downloadable flag if there's an in-flight download, just in case. + if config.state == nil { + guard config.downloadable else { + /// Session can't be downloaded (maybe Lab or download not available yet) + downloadIndicator.isHidden = true + downloadButton.isHidden = true + clipButton.isHidden = true + resetDownloadButton() + return + } + } + + switch config.state { + case .waiting: + downloadIndicator.isHidden = false downloadButton.isHidden = true + downloadButton.toolTip = "Preparing download" clipButton.isHidden = true + downloadIndicator.isIndeterminate = true + downloadIndicator.startAnimating() + case .downloading(let progress): + downloadButton.toolTip = "Downloading: \(progress.formattedDownloadPercentage())" + downloadIndicator.isHidden = false + downloadButton.isHidden = true + clipButton.isHidden = true + + if progress < 0 { + downloadIndicator.isIndeterminate = true + downloadIndicator.startAnimating() + } else { + downloadIndicator.isIndeterminate = false + downloadIndicator.progress = Float(progress) + } + case .paused, .cancelled, .none, .failed: resetDownloadButton() + downloadIndicator.isHidden = true + downloadButton.isHidden = false + clipButton.isHidden = true + if case .failed(let message) = config.state { + downloadButton.toolTip = message + } else { + downloadButton.toolTip = nil + } + case .completed: + downloadButton.toolTip = "Delete downloaded video" + downloadButton.isHidden = false + downloadIndicator.isHidden = true + downloadButton.image = #imageLiteral(resourceName: "trash") + downloadButton.action = #selector(SessionActionsViewController.deleteDownload) + clipButton.isHidden = false } } @@ -279,3 +319,18 @@ class SessionActionsViewController: NSViewController { delegate?.sessionActionsDidSelectCancelDownload(sender) } } + +extension NumberFormatter { + static let downloadPercent: NumberFormatter = { + let f = NumberFormatter() + f.maximumFractionDigits = 0 + f.numberStyle = .percent + return f + }() +} + +extension Double { + func formattedDownloadPercentage() -> String { + NumberFormatter.downloadPercent.string(from: NSNumber(value: self)) ?? "" + } +} From a84607defb0eea1f85e326bdf8aff1fe28116bac Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 13:58:48 -0300 Subject: [PATCH 07/12] New download management UI --- WWDC.xcodeproj/project.pbxproj | 4 + WWDC/DownloadManagerView.swift | 257 +++++++++++++++++++ WWDC/DownloadsManagementViewController.swift | 153 ++++------- WWDC/TitleBarButtonsViewController.swift | 23 +- 4 files changed, 322 insertions(+), 115 deletions(-) create mode 100644 WWDC/DownloadManagerView.swift diff --git a/WWDC.xcodeproj/project.pbxproj b/WWDC.xcodeproj/project.pbxproj index 350be798..a06f996b 100644 --- a/WWDC.xcodeproj/project.pbxproj +++ b/WWDC.xcodeproj/project.pbxproj @@ -211,6 +211,7 @@ F4F189762C0773C9006EA9A2 /* MacPreviewUtils in Frameworks */ = {isa = PBXBuildFile; productRef = F4F189752C0773C9006EA9A2 /* MacPreviewUtils */; }; F4F189782C0774BE006EA9A2 /* PUIPlaybackSpeedToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F189772C0774BE006EA9A2 /* PUIPlaybackSpeedToggle.swift */; }; F4F1897A2C0775C5006EA9A2 /* NumericContentTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F189792C0775C5006EA9A2 /* NumericContentTransition.swift */; }; + F4F2792A2C0F777200A029A3 /* DownloadManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F279292C0F777200A029A3 /* DownloadManagerView.swift */; }; F4FB069F2A2148EA00799F84 /* ExploreTabRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FB069E2A2148EA00799F84 /* ExploreTabRootView.swift */; }; F4FB06A12A21493B00799F84 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FB06A02A21493B00799F84 /* PreviewSupport.swift */; }; F4FB06BF2A216C1F00799F84 /* RemoteGlyph.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FB06BE2A216C1F00799F84 /* RemoteGlyph.swift */; }; @@ -500,6 +501,7 @@ F4F189772C0774BE006EA9A2 /* PUIPlaybackSpeedToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUIPlaybackSpeedToggle.swift; sourceTree = ""; }; F4F189792C0775C5006EA9A2 /* NumericContentTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumericContentTransition.swift; sourceTree = ""; }; F4F1C9A22A24FF50002C3709 /* TeamID.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TeamID.xcconfig; sourceTree = ""; }; + F4F279292C0F777200A029A3 /* DownloadManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerView.swift; sourceTree = ""; }; F4FB069E2A2148EA00799F84 /* ExploreTabRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreTabRootView.swift; sourceTree = ""; }; F4FB06A02A21493B00799F84 /* PreviewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupport.swift; sourceTree = ""; }; F4FB06BE2A216C1F00799F84 /* RemoteGlyph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteGlyph.swift; sourceTree = ""; }; @@ -558,6 +560,7 @@ 4D66CA51217E2C9B0006A8C9 /* DownloadsManagementTableView.swift */, 4DDF6A772177A00C008E5539 /* DownloadsManagementTableCellView.swift */, 4DBA2F7520FE71BF00ED0253 /* DownloadsStatusButton.swift */, + F4F279292C0F777200A029A3 /* DownloadManagerView.swift */, ); name = Downloads; sourceTree = ""; @@ -1586,6 +1589,7 @@ F4578D9F2A26A218005B311A /* WWDCAppCommand.swift in Sources */, F4FB06BF2A216C1F00799F84 /* RemoteGlyph.swift in Sources */, DDDF807E20BA4FFA007284F8 /* WWDCHorizontalScrollView.swift in Sources */, + F4F2792A2C0F777200A029A3 /* DownloadManagerView.swift in Sources */, DD7F387D1EAC113A002D8C00 /* WWDCTextField.swift in Sources */, DDB352801EC7C4CA00254815 /* Arguments.swift in Sources */, 4D9EE96424BCE097001B1720 /* FilterState.swift in Sources */, diff --git a/WWDC/DownloadManagerView.swift b/WWDC/DownloadManagerView.swift new file mode 100644 index 00000000..99b34083 --- /dev/null +++ b/WWDC/DownloadManagerView.swift @@ -0,0 +1,257 @@ +import SwiftUI +import ConfCore +import PlayerUI + +private typealias Metrics = DownloadsManagementViewController.Metrics + +struct DownloadManagerView: View { + @EnvironmentObject private var manager: MediaDownloadManager + @ObservedObject var controller: DownloadsManagementViewController + + var body: some View { + List { + ForEach(manager.downloads) { download in + DownloadItemView(download: download) + .tag(download) + } + } + .frame(minWidth: Metrics.defaultWidth, maxWidth: .infinity, minHeight: Metrics.defaultHeight, maxHeight: .infinity) + .animation(.smooth, value: manager.downloads.count) + } +} + +struct DownloadItemView: View { + @EnvironmentObject private var manager: MediaDownloadManager + @ObservedObject var download: MediaDownload + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(download.title) + .font(.headline) + + Spacer() + + DownloadActionsView(download: download) + } + + DownloadProgressView(download: download) + } + .wwdc_listRowSeparatorHidden() + .contentShape(Rectangle()) + .padding(8) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { contextActions } + .contextMenu { contextActions } + } + + @ViewBuilder + private var contextActions: some View { + if download.isCompleted { + Button("Clear") { + manager.clear(download) + } + } else if download.isPaused { + Button("Resume") { + catchingErrors { + try manager.resume(download) + } + } + } else if download.isFailed { + Button("Try Again") { + retry(download) + } + } else { + Button("Pause") { + catchingErrors { + try manager.pause(download) + } + } + + Button("Cancel", role: .destructive) { + catchingErrors { + try manager.cancel(download) + } + } + } + } + + private func catchingErrors(perform action: () throws -> Void) { + do { + try action() + } catch { + NSAlert(error: error).runModal() + } + } + + private func retry(_ download: MediaDownload) { + Task { + do { + try await manager.retry(download) + } catch { + NSAlert(error: error).runModal() + } + } + } +} + +struct DownloadProgressView: View { + @EnvironmentObject private var manager: MediaDownloadManager + @ObservedObject var download: MediaDownload + + var body: some View { + Group { + switch download.state { + case .waiting: + progressState(message: "Starting…") + case .downloading: + progressState() + case .paused: + progressState(message: "Paused") + case .failed(let message): + progressState(message: message) + .foregroundStyle(.red) + case .completed: + progressState(message: "Finished!") + case .cancelled: + progressState(message: "Canceled") + } + } + } + + @ViewBuilder + private func progressState(message: String? = nil) -> some View { + VStack(alignment: .leading, spacing: 1) { + if let progress = download.progress { + ProgressView(value: min(1, max(0, progress))) + .opacity(download.isPaused ? 0.5 : 1) + .opacity(progress >= 1 ? 0.2 : 1) + } else { + ProgressView(value: download.isCompleted ? 1 : nil, total: 1) + .opacity(0.5) + } + + progressDetail(message: message) + } + } + + @ViewBuilder + private func progressDetail(message: String?) -> some View { + HStack { + progressIndicator(message: message) + + Spacer() + + if !download.isPaused, let stats = download.stats, let formattedETA = stats.formattedETA, let eta = stats.eta, eta > 0 { + Text("\(formattedETA)") + .numericContentTransition(value: eta, countsDown: true) + } else if download.isCompleted { + clearButton + } + } + .progressViewStyle(.linear) + .monospacedDigit() + .font(.subheadline) + .foregroundStyle(.secondary) + .animation(.smooth, value: download.stats?.eta) + } + + @ViewBuilder + private func progressIndicator(message: String?) -> some View { + if let progress = download.progress, progress < 1 { + Text(progress, format: .percent.precision(.fractionLength(0))) + .font(.subheadline.weight(.medium)) + .numericContentTransition(value: progress) + } else if let message { + Text(message) + } + } + + @ViewBuilder + private var clearButton: some View { + Button { + manager.clear(download) + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.borderless) + } +} + +struct DownloadActionsView: View { + @EnvironmentObject private var manager: MediaDownloadManager + @ObservedObject var download: MediaDownload + + var body: some View { + Group { + switch download.state { + case .waiting: + pauseButton + case .downloading: + pauseButton + case .paused: + resumeButton + case .failed(let message): + errorButton(with: message) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .cancelled: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + } + .progressViewStyle(.circular) + .buttonStyle(.borderless) + } + + @ViewBuilder + private var pauseButton: some View { + Button { + handlingErrors { + try manager.pause(download) + } + } label: { + Image(systemName: "pause.circle.fill") + } + } + + @ViewBuilder + private var resumeButton: some View { + Button { + handlingErrors { + try manager.resume(download) + } + } label: { + Image(systemName: "play.circle.fill") + } + } + + @ViewBuilder + private func errorButton(with message: String) -> some View { + Button { + NSAlert(error: message).runModal() + } label: { + Image(systemName: "exclamationmark.circle.fill") + } + .foregroundStyle(.red) + } + + private func handlingErrors(perform action: () throws -> Void) { + do { + try action() + } catch { + NSAlert(error: error).runModal() + } + } +} + +extension View { + @ViewBuilder + func wwdc_listRowSeparatorHidden(_ hidden: Bool = true) -> some View { + if #available(macOS 13.0, *) { + listRowSeparator(hidden ? .hidden : .automatic) + } else { + self + } + } +} diff --git a/WWDC/DownloadsManagementViewController.swift b/WWDC/DownloadsManagementViewController.swift index 2e972128..7847f62a 100644 --- a/WWDC/DownloadsManagementViewController.swift +++ b/WWDC/DownloadsManagementViewController.swift @@ -8,92 +8,43 @@ import ConfCore import Combine +import SwiftUI -class DownloadsManagementViewController: NSViewController { +final class DownloadsManagementViewController: NSViewController, ObservableObject { - fileprivate struct Metrics { - static let topPadding: CGFloat = 0 - static let tableGridLineHeight: CGFloat = 2 - static let rowHeight: CGFloat = 64 - static let popOverDesiredWidth: CGFloat = 400 - static let popOverDesiredHeight: CGFloat = 500 + struct Metrics { + static let defaultWidth: CGFloat = 400 + static let defaultHeight: CGFloat = 200 } - lazy var tableView: DownloadsManagementTableView = { - let v = DownloadsManagementTableView() - - v.wantsLayer = true - v.focusRingType = .none - v.allowsEmptySelection = true - v.allowsMultipleSelection = false - v.backgroundColor = .clear - v.headerView = nil - v.rowHeight = Metrics.rowHeight - v.autoresizingMask = [.width, .height] - v.floatsGroupRows = true - v.gridStyleMask = .solidHorizontalGridLineMask - v.gridColor = NSColor.gridColor - v.selectionHighlightStyle = .none - v.style = .plain - - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "download")) - v.addTableColumn(column) - - return v - }() - - lazy var scrollView: NSScrollView = { - let v = NSScrollView() - - v.focusRingType = .none - v.drawsBackground = false - v.borderType = .noBorder - v.documentView = self.tableView - v.hasVerticalScroller = true - v.autohidesScrollers = true - v.hasHorizontalScroller = false - v.translatesAutoresizingMaskIntoConstraints = false - - return v - }() + private lazy var hostingView: NSView = NSHostingView(rootView: DownloadManagerView(controller: self).environmentObject(downloadManager)) override func loadView() { - tableView.delegate = self - tableView.dataSource = self + view = DownloadManagementRootView(frame: NSRect(x: 0, y: 0, width: Metrics.defaultWidth, height: Metrics.defaultHeight)) - view = NSView(frame: NSRect(x: 0, y: 0, width: Metrics.popOverDesiredWidth, height: Metrics.popOverDesiredHeight)) + hostingView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(scrollView) - scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.topPadding).isActive = true - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -Metrics.topPadding).isActive = true + view.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingView.topAnchor.constraint(equalTo: view.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) } let downloadManager: MediaDownloadManager let storage: Storage private lazy var cancellables: Set = [] - var downloads = [MediaDownload]() { - didSet { - if downloads.count == 0 { - dismiss(nil) - } else if downloads != oldValue { - tableView.reloadData() - let height = min( - (Metrics.rowHeight + Metrics.tableGridLineHeight) * CGFloat(downloads.count) + Metrics.topPadding * 2, - preferredMaximumSize.height - ) - self.preferredContentSize = NSSize(width: Metrics.popOverDesiredWidth, height: height) - } - } - } + @Published private(set) var downloads = [MediaDownload]() override var preferredMaximumSize: NSSize { var mainSize = NSApp.windows.filter { $0.identifier == .mainWindow }.compactMap { $0 as? WWDCWindow }.first?.frame.size mainSize?.height -= 50 - return mainSize ?? NSSize(width: Metrics.popOverDesiredWidth, height: Metrics.popOverDesiredHeight) + return mainSize ?? NSSize(width: Metrics.defaultWidth, height: Metrics.defaultHeight) } init(downloadManager: MediaDownloadManager, storage: Storage) { @@ -105,59 +56,37 @@ class DownloadsManagementViewController: NSViewController { downloadManager .$downloads .throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] in - self?.downloads = $0.sorted(by: MediaDownload.sortingFunction) - } - .store(in: &cancellables) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -extension DownloadsManagementViewController: NSTableViewDataSource, NSTableViewDelegate { - - private struct Constants { - static let downloadStatusCellIdentifier = "downloadStatusCellIdentifier" - static let rowIdentifier = "row" - } + .map({ $0.sorted(by: MediaDownload.sortingFunction) }) + .assign(to: &$downloads) - func numberOfRows(in tableView: NSTableView) -> Int { - return downloads.count - } - - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - let download = downloads[row] - guard let session = storage.session(with: download.id) else { return nil } - - var cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: Constants.downloadStatusCellIdentifier), owner: tableView) as? DownloadsManagementTableCellView - - if cell == nil { - cell = DownloadsManagementTableCellView(frame: .zero) - cell?.identifier = NSUserInterfaceItemIdentifier(rawValue: Constants.downloadStatusCellIdentifier) + $downloads.map(\.count).removeDuplicates().sink { [weak self] count in + self?.updatePreferredSize(downloadCount: count) } - - cell?.viewModel = DownloadViewModel(download: download, status: download.$state.eraseToAnyPublisher(), session: session) - - return cell + .store(in: &cancellables) } - func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { - var rowView = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: Constants.rowIdentifier), owner: tableView) as? DownloadsManagementTableRowView + private func updatePreferredSize(downloadCount: Int) { + guard downloadCount > 0 else { + dismiss(nil) + return + } - if rowView == nil { - rowView = DownloadsManagementTableRowView(frame: .zero) - rowView?.identifier = NSUserInterfaceItemIdentifier(rawValue: Constants.rowIdentifier) + /// Do a bit of introspection into the SwiftUI hierarchy to get the desired height for the scrollable contents. + /// If this fails, then the popover/window will just use the default size. + guard let scrollView = hostingView.subviews.first?.subviews.first as? NSScrollView, + let documentView = scrollView.documentView + else { + self.preferredContentSize = NSSize(width: Metrics.defaultWidth, height: Metrics.defaultHeight) + return } - rowView?.isLastRow = row == downloads.index(before: downloads.endIndex) + let height = min(documentView.fittingSize.height, preferredMaximumSize.height) - return rowView + self.preferredContentSize = NSSize(width: Metrics.defaultWidth, height: height) } - func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { - return Metrics.rowHeight + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } } @@ -167,3 +96,7 @@ extension DownloadsManagementViewController: NSPopoverDelegate { return true } } + +private final class DownloadManagementRootView: NSView { + override var mouseDownCanMoveWindow: Bool { true } +} diff --git a/WWDC/TitleBarButtonsViewController.swift b/WWDC/TitleBarButtonsViewController.swift index b6a5adf1..8b25104b 100644 --- a/WWDC/TitleBarButtonsViewController.swift +++ b/WWDC/TitleBarButtonsViewController.swift @@ -104,15 +104,28 @@ final class TitleBarButtonsViewController: NSViewController { stackView.insertArrangedSubview(hostingView, at: 0) } + private var isPresentingDownloadManagementPopover: Bool { + guard let presentedViewControllers, let managementViewController else { return false } + return presentedViewControllers.contains(managementViewController) + } + @objc func toggleDownloadsManagementPopover(sender: NSButton) { - if managementViewController == nil { - let managementViewController = DownloadsManagementViewController(downloadManager: downloadManager, storage: storage) - self.managementViewController = managementViewController - present(managementViewController, asPopoverRelativeTo: sender.bounds, of: sender, preferredEdge: .maxY, behavior: .semitransient) - } else { + guard !isPresentingDownloadManagementPopover else { managementViewController?.dismiss(nil) + return + } + + let controller: DownloadsManagementViewController + + if let managementViewController { + controller = managementViewController + } else { + controller = DownloadsManagementViewController(downloadManager: downloadManager, storage: storage) + self.managementViewController = controller } + + present(controller, asPopoverRelativeTo: sender.bounds, of: sender, preferredEdge: .maxY, behavior: .semitransient) } } From 6f9edf9e32549e2f23550f6299ab3a79fd570916 Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 14:02:09 -0300 Subject: [PATCH 08/12] Removed custom root view --- WWDC/DownloadsManagementViewController.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/WWDC/DownloadsManagementViewController.swift b/WWDC/DownloadsManagementViewController.swift index 7847f62a..b302844a 100644 --- a/WWDC/DownloadsManagementViewController.swift +++ b/WWDC/DownloadsManagementViewController.swift @@ -20,7 +20,7 @@ final class DownloadsManagementViewController: NSViewController, ObservableObjec private lazy var hostingView: NSView = NSHostingView(rootView: DownloadManagerView(controller: self).environmentObject(downloadManager)) override func loadView() { - view = DownloadManagementRootView(frame: NSRect(x: 0, y: 0, width: Metrics.defaultWidth, height: Metrics.defaultHeight)) + view = NSView(frame: NSRect(x: 0, y: 0, width: Metrics.defaultWidth, height: Metrics.defaultHeight)) hostingView.translatesAutoresizingMaskIntoConstraints = false @@ -95,8 +95,5 @@ extension DownloadsManagementViewController: NSPopoverDelegate { func popoverShouldDetach(_ popover: NSPopover) -> Bool { return true } -} -private final class DownloadManagementRootView: NSView { - override var mouseDownCanMoveWindow: Bool { true } } From eca88222f2d0518886c28cb341df2b68ab7ab59f Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 14:30:47 -0300 Subject: [PATCH 09/12] Updating session download button state live with current download progress --- WWDC/SessionActionsViewController.swift | 65 +++++++++++++++++-------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/WWDC/SessionActionsViewController.swift b/WWDC/SessionActionsViewController.swift index 283d1a03..6a4999e2 100644 --- a/WWDC/SessionActionsViewController.swift +++ b/WWDC/SessionActionsViewController.swift @@ -165,10 +165,15 @@ class SessionActionsViewController: NSViewController { updateBindings() } - private typealias DownloadButtonConfig = (downloadable: Bool, state: MediaDownloadState?) + private struct DownloadButtonConfig { + var hasDownloadableContent: Bool + var isDownloaded: Bool + var inFlightDownload: MediaDownload? + } private func updateBindings() { cancellables = [] + downloadStateCancellable = nil guard let viewModel = viewModel else { return } @@ -189,26 +194,29 @@ class SessionActionsViewController: NSViewController { let downloadID = viewModel.session.downloadIdentifier /// Download state for existing in-flight download, or `nil` if there's no in-flight download for the session. - let inFlightDownloadSignal = MediaDownloadManager.shared.$downloads.map { $0.first(where: { $0.id == downloadID }) } + let inFlightDownloadSignal: AnyPublisher = MediaDownloadManager.shared.$downloads + .map { $0.first(where: { $0.id == downloadID }) } .eraseToAnyPublisher() - .replaceErrorWithEmpty() /// `true` if the session has already been downloaded. - let alreadyDownloaded = viewModel.session + let alreadyDownloaded: AnyPublisher = viewModel.session .valuePublisher(keyPaths: ["isDownloaded"]) .replaceErrorWithEmpty() + .eraseToAnyPublisher() /// This publisher includes a flag indicating whether the session can be downloaded, as well as the current download state, if any. let downloadButtonConfig: AnyPublisher = Publishers.CombineLatest(inFlightDownloadSignal, alreadyDownloaded) .map { inFlightDownload, session in if session.isDownloaded { - return (true, MediaDownloadState.completed) + return DownloadButtonConfig(hasDownloadableContent: true, isDownloaded: true) } else { - guard !session.assets(matching: Session.mediaDownloadVariants).isEmpty else { return (false, nil) } + guard !session.assets(matching: Session.mediaDownloadVariants).isEmpty else { + return DownloadButtonConfig(hasDownloadableContent: false, isDownloaded: false) + } if let inFlightDownload { - return (true, inFlightDownload.state) + return DownloadButtonConfig(hasDownloadableContent: true, isDownloaded: false, inFlightDownload: inFlightDownload) } else { - return (true, nil) + return DownloadButtonConfig(hasDownloadableContent: true, isDownloaded: false) } } } @@ -222,20 +230,37 @@ class SessionActionsViewController: NSViewController { .store(in: &cancellables) } + private var downloadStateCancellable: AnyCancellable? + private func configureDownloadButton(with config: DownloadButtonConfig) { - /// Ignore downloadable flag if there's an in-flight download, just in case. - if config.state == nil { - guard config.downloadable else { - /// Session can't be downloaded (maybe Lab or download not available yet) - downloadIndicator.isHidden = true - downloadButton.isHidden = true - clipButton.isHidden = true - resetDownloadButton() - return - } + downloadStateCancellable = nil + + guard config.hasDownloadableContent else { + /// Session can't be downloaded (maybe Lab or download not available yet) + downloadIndicator.isHidden = true + downloadButton.isHidden = true + clipButton.isHidden = true + resetDownloadButton() + return } - switch config.state { + guard !config.isDownloaded else { + updateDownloadButton(with: .completed) + return + } + + guard let inFlightDownload = config.inFlightDownload else { + updateDownloadButton(with: nil) + return + } + + downloadStateCancellable = inFlightDownload.$state.receive(on: DispatchQueue.main).sink { [weak self] state in + self?.updateDownloadButton(with: state) + } + } + + private func updateDownloadButton(with state: MediaDownloadState?) { + switch state { case .waiting: downloadIndicator.isHidden = false downloadButton.isHidden = true @@ -261,7 +286,7 @@ class SessionActionsViewController: NSViewController { downloadIndicator.isHidden = true downloadButton.isHidden = false clipButton.isHidden = true - if case .failed(let message) = config.state { + if case .failed(let message) = state { downloadButton.toolTip = message } else { downloadButton.toolTip = nil From daaffe25b934a12bf457acdac612d60c59c7aece Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 14:31:21 -0300 Subject: [PATCH 10/12] Changed prefer HLS preference to false by default --- WWDC/Preferences.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WWDC/Preferences.swift b/WWDC/Preferences.swift index 505cfc32..6ce932cc 100644 --- a/WWDC/Preferences.swift +++ b/WWDC/Preferences.swift @@ -29,7 +29,7 @@ final class Preferences { defaults.register(defaults: [ "localVideoStoragePath": Self.defaultLocalVideoStoragePath, "includeAppBannerInSharedClips": true, - "preferHLSVideoDownload": true + "preferHLSVideoDownload": false ]) } From b30be39ae0c799a580988ccb9cc6de3f49c1b19c Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 14:36:44 -0300 Subject: [PATCH 11/12] Improved download button state management --- WWDC/SessionActionsViewController.swift | 37 +++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/WWDC/SessionActionsViewController.swift b/WWDC/SessionActionsViewController.swift index 6a4999e2..557227fd 100644 --- a/WWDC/SessionActionsViewController.swift +++ b/WWDC/SessionActionsViewController.swift @@ -207,7 +207,7 @@ class SessionActionsViewController: NSViewController { /// This publisher includes a flag indicating whether the session can be downloaded, as well as the current download state, if any. let downloadButtonConfig: AnyPublisher = Publishers.CombineLatest(inFlightDownloadSignal, alreadyDownloaded) .map { inFlightDownload, session in - if session.isDownloaded { + if session.isDownloaded, MediaDownloadManager.shared.hasDownloadedMedia(for: session) { return DownloadButtonConfig(hasDownloadableContent: true, isDownloaded: true) } else { guard !session.assets(matching: Session.mediaDownloadVariants).isEmpty else { @@ -253,13 +253,36 @@ class SessionActionsViewController: NSViewController { updateDownloadButton(with: nil) return } - + downloadStateCancellable = inFlightDownload.$state.receive(on: DispatchQueue.main).sink { [weak self] state in self?.updateDownloadButton(with: state) } } private func updateDownloadButton(with state: MediaDownloadState?) { + guard let session = viewModel?.session else { return } + + func applyStartDownloadState() { + resetDownloadButton() + downloadIndicator.isHidden = true + downloadButton.isHidden = false + clipButton.isHidden = true + if case .failed(let message) = state { + downloadButton.toolTip = message + } else { + downloadButton.toolTip = nil + } + } + + /// We may have a download that's in completed state, but where the file has already been deleted, + /// in which case we show the button state to start the download. + if case .completed = state { + guard MediaDownloadManager.shared.hasDownloadedMedia(for: session) else { + applyStartDownloadState() + return + } + } + switch state { case .waiting: downloadIndicator.isHidden = false @@ -282,15 +305,7 @@ class SessionActionsViewController: NSViewController { downloadIndicator.progress = Float(progress) } case .paused, .cancelled, .none, .failed: - resetDownloadButton() - downloadIndicator.isHidden = true - downloadButton.isHidden = false - clipButton.isHidden = true - if case .failed(let message) = state { - downloadButton.toolTip = message - } else { - downloadButton.toolTip = nil - } + applyStartDownloadState() case .completed: downloadButton.toolTip = "Delete downloaded video" downloadButton.isHidden = false From 26e1987208d6906470be82cd0b702f8560c42ca5 Mon Sep 17 00:00:00 2001 From: Guilherme Rambo Date: Tue, 4 Jun 2024 16:39:10 -0300 Subject: [PATCH 12/12] Implemented toggle in settings to enable/disable HLS downloads --- WWDC/GeneralPreferencesViewController.swift | 11 ++ WWDC/Preferences.storyboard | 120 +++++++++++++++++--- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/WWDC/GeneralPreferencesViewController.swift b/WWDC/GeneralPreferencesViewController.swift index e03aa820..e2f048d6 100644 --- a/WWDC/GeneralPreferencesViewController.swift +++ b/WWDC/GeneralPreferencesViewController.swift @@ -48,6 +48,9 @@ final class GeneralPreferencesViewController: WWDCWindowContentViewController { @IBOutlet weak var downloadsFolderLabel: NSTextField! + @IBOutlet weak var preferHLSDownloadsLabel: NSTextField! + @IBOutlet weak var preferHLSDownloadsSwitch: NSSwitch! + @IBOutlet weak var downloadsFolderIntroLabel: NSTextField! @IBOutlet weak var searchIntroLabel: NSTextField! @IBOutlet weak var includeBookmarksLabel: NSTextField! @@ -73,6 +76,7 @@ final class GeneralPreferencesViewController: WWDCWindowContentViewController { downloadsFolderIntroLabel.textColor = .prefsPrimaryText searchIntroLabel.textColor = .prefsPrimaryText + preferHLSDownloadsLabel.textColor = .prefsPrimaryText includeBookmarksLabel.textColor = .prefsPrimaryText includeTranscriptsLabel.textColor = .prefsPrimaryText refreshAutomaticallyLabel.textColor = .prefsPrimaryText @@ -86,6 +90,7 @@ final class GeneralPreferencesViewController: WWDCWindowContentViewController { dividerC.fillColor = .separatorColor dividerE.fillColor = .separatorColor + preferHLSDownloadsSwitch.isOn = Preferences.shared.preferHLSVideoDownload searchInTranscriptsSwitch.isOn = Preferences.shared.searchInTranscripts searchInBookmarksSwitch.isOn = Preferences.shared.searchInBookmarks refreshPeriodicallySwitch.isOn = Preferences.shared.refreshPeriodically @@ -251,6 +256,12 @@ final class GeneralPreferencesViewController: WWDCWindowContentViewController { return response == .alertSecondButtonReturn } + @IBAction func preferHLSDownloadsSwitchAction(_ sender: NSSwitch) { + guard sender.isOn != Preferences.shared.preferHLSVideoDownload else { return } + + Preferences.shared.preferHLSVideoDownload = sender.isOn + } + // MARK: - Transcript languages private lazy var languagesProvider = TranscriptLanguagesProvider() diff --git a/WWDC/Preferences.storyboard b/WWDC/Preferences.storyboard index d7adae01..5b4d4627 100644 --- a/WWDC/Preferences.storyboard +++ b/WWDC/Preferences.storyboard @@ -1,8 +1,8 @@ - + - + @@ -11,7 +11,7 @@ - + @@ -24,7 +24,7 @@ - + @@ -92,7 +92,7 @@ - + @@ -100,7 +100,7 @@ - + @@ -127,7 +127,7 @@ - + @@ -135,10 +135,10 @@ - + - + @@ -146,7 +146,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + - + @@ -216,7 +269,7 @@ - + @@ -230,7 +283,7 @@ - + @@ -272,7 +325,7 @@ - + @@ -280,7 +333,7 @@ - + @@ -346,6 +399,8 @@ + + @@ -359,6 +414,41 @@ + + + + + + + + + + + + + + + + With this option enabled, the app will download the HLS version of the videos for offline playback. The HLS version includes all subtitles, but it has some downsides when compared to the regular HD download version that's used when this option is disabled. Downsides of the HLS version include slower seek performance and the inability to export clips from downloaded videos. + +This setting only applies to new downloads, any videos that have already been downloaded will remain. You can download the new version by deleting and re-downloading videos. + + + + + + + + + + + + + + + + +