diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 65920f9d..2c008a44 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -38,7 +38,7 @@ C4B265F5285A5A6600E1B5C3 /* LocalLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */; }; C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D0677428106D0C00B8F875 /* DataClasses.swift */; }; E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */; }; - E9D38158289A0A6F0019EEED /* LibraryItemDownloadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */; }; + E9D3815C289E0C9B0019EEED /* DownloadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D3815B289E0C9B0019EEED /* DownloadItem.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -77,7 +77,7 @@ C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibrary.swift; sourceTree = ""; }; C4D0677428106D0C00B8F875 /* DataClasses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataClasses.swift; sourceTree = ""; }; E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalLibraryExtensions.swift; sourceTree = ""; }; - E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItemDownloadSession.swift; sourceTree = ""; }; + E9D3815B289E0C9B0019EEED /* DownloadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadItem.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -143,6 +143,7 @@ C4D0677428106D0C00B8F875 /* DataClasses.swift */, C4B265F4285A5A6600E1B5C3 /* LocalLibrary.swift */, E99C8C922883A00F00E3279A /* LocalLibraryExtensions.swift */, + E9D3815B289E0C9B0019EEED /* DownloadItem.swift */, 3A90295E280968E700E1D427 /* PlaybackReport.swift */, 4DF74911287105C600AC7814 /* DeviceSettings.swift */, ); @@ -158,7 +159,6 @@ 3AF1970B2806E2590096F747 /* ApiClient.swift */, 3AB34052280829BF0039308B /* Extensions.swift */, 3AB34054280832720039308B /* PlayerEvents.swift */, - E9D38157289A0A6F0019EEED /* LibraryItemDownloadSession.swift */, ); path = util; sourceTree = ""; @@ -335,6 +335,7 @@ 4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */, 3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */, 3AB34053280829BF0039308B /* Extensions.swift in Sources */, + E9D3815C289E0C9B0019EEED /* DownloadItem.swift in Sources */, 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */, 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */, 4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */, @@ -344,7 +345,6 @@ C4D0677528106D0C00B8F875 /* DataClasses.swift in Sources */, 4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */, 3AB34055280832720039308B /* PlayerEvents.swift in Sources */, - E9D38158289A0A6F0019EEED /* LibraryItemDownloadSession.swift in Sources */, E99C8C932883A00F00E3279A /* LocalLibraryExtensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 5ffa1b7e..e3225579 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -49,6 +49,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Realm.registerRealmables(LocalFile.self) Realm.registerRealmables(LocalMediaProgress.self) + // Download item + Realm.registerRealmables(DownloadItem.self) + Realm.registerRealmables(DownloadItemPart.self) + return true } diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 46c475b7..b7f7330c 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -9,37 +9,129 @@ import Foundation import Capacitor @objc(AbsDownloader) -public class AbsDownloader: CAPPlugin { +public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { + + private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: .current) + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + NSLog("Finished downloading \(downloadTask.taskDescription ?? "Unknown Task")") + + guard let downloadItemPartId = downloadTask.taskDescription else { return } + + let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) + guard let downloadItem = downloadItem else { + NSLog("Download item part (%@) not found! Unable to move file!", downloadItemPartId) + return + } + + NSLog("Found downloadItem(%@)", downloadItem.id) + self.moveLibraryItemToFileDirectory(tempUrl: location) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + NSLog("Error downloading \(task.taskDescription ?? "Unknown Task"): \(error ?? "Unknown Error")") + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + NSLog("Received download status \(downloadTask.taskDescription ?? "Unknown Task"): \(totalBytesWritten)") + } + @objc func downloadLibraryItem(_ call: CAPPluginCall) { let libraryItemId = call.getString("libraryItemId") let episodeId = call.getString("episodeId") NSLog("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "")") + guard let libraryItemId = libraryItemId else { call.resolve(); return; } - ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId!, episodeId: episodeId) { libraryItem in + ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { libraryItem in if (libraryItem == nil) { NSLog("Library item not found") - call.resolve() } else { NSLog("Got library item from server \(libraryItem!.id)") - Task { - do { - let downloadSession = LibraryItemDownloadSession(libraryItem!) - let localLibraryItem = try await downloadSession.startDownload() - Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) - } catch { - NSLog("Failed to download \(error)") - } - call.resolve() + do { + try self.startLibraryItemDownload(libraryItem!) + //Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) + } catch { + NSLog("Failed to download \(error)") } } + call.resolve() } } + + private func startLibraryItemDownload(_ item: LibraryItem) throws { + guard let tracks = item.media.tracks else { + throw LibraryItemDownloadError.noTracks + } + + // Queue up everything for downloading + var downloadItem = DownloadItem(libraryItem: item, server: Store.serverConfig!) + downloadItem.downloadItemParts = try tracks.enumerated().map({ i, track in + try startLibraryItemTrackDownload(item: item, position: i, track: track) + }) + + // Persist in the database before status start coming in + Database.shared.saveDownloadItem(downloadItem) + + // Start all the downloads + for downloadItemPart in downloadItem.downloadItemParts { + downloadItemPart.task.resume() + } + } + + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) throws -> DownloadItemPart { + NSLog("TRACK \(track.contentUrl!)") + + // If we don't name metadata, then we can't proceed + guard let filename = track.metadata?.filename else { + throw LibraryItemDownloadError.noMetadata + } + + let serverUrl = urlForTrack(item: item, track: track) + let itemDirectory = try createLibraryItemFileDirectory(item: item) + let localUrl = itemDirectory.appendingPathComponent("\(filename)") + + let task = session.downloadTask(with: serverUrl) + var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: nil) + + // Store the id on the task so the download item can be pulled from the database later + task.taskDescription = downloadItemPart.id + downloadItemPart.task = task + + return downloadItemPart + } + + private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { + // filename needs to be encoded otherwise would just use contentUrl + let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) + let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" + return URL(string: urlstr)! + } + + private func createLibraryItemFileDirectory(item: LibraryItem) throws -> URL { + let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") + NSLog("ITEM DIR \(itemDirectory)") + + do { + try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) + } catch { + NSLog("Failed to CREATE LI DIRECTORY \(error)") + throw LibraryItemDownloadError.failedDirectory + } + + return itemDirectory + } + + private func moveLibraryItemToFileDirectory(tempUrl: URL) { + //try FileManager.default.moveItem(at: tempUrl, to: localUrl) + } + } -struct DownloadItem: Codable { - var isDownloading = false - var progress: Float = 0 - var resumeData: Data? -// var task: URLSessionDownloadTask? +enum LibraryItemDownloadError: String, Error { + case noTracks = "No tracks on library item" + case noMetadata = "No metadata for track, unable to download" + case failedDirectory = "Failed to create directory" + case failedDownload = "Failed to download item" } diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift new file mode 100644 index 00000000..c0c9923d --- /dev/null +++ b/ios/App/Shared/models/DownloadItem.swift @@ -0,0 +1,81 @@ +// +// DownloadItem.swift +// App +// +// Created by Ron Heft on 8/5/22. +// + +import Foundation +import Unrealm + +struct DownloadItem: Realmable, Codable { + var id: String = UUID().uuidString + var libraryItemId: String? + var episodeId: String? + var userMediaProgress: MediaProgress? + var serverConnectionConfigId: String? + var serverAddress: String? + var serverUserId: String? + var mediaType: String? + var itemTitle: String? + var media: MediaType? + var downloadItemParts: [DownloadItemPart] = [] + + static func primaryKey() -> String? { + return "id" + } + + static func indexedProperties() -> [String] { + ["libraryItemId"] + } +} + +extension DownloadItem { + init(libraryItem: LibraryItem, server: ServerConnectionConfig) { + self.libraryItemId = libraryItem.id + //self.episodeId // TODO + self.userMediaProgress = libraryItem.userMediaProgress + self.serverConnectionConfigId = server.id + self.serverAddress = server.address + self.serverUserId = server.userId + self.mediaType = libraryItem.mediaType + self.itemTitle = libraryItem.media.metadata.title + self.media = libraryItem.media + } +} + +struct DownloadItemPart: Realmable, Codable { + var id: String = UUID().uuidString + var filename: String? + var finalDestinationPath: String? + var itemTitle: String? + var serverPath: String? + var audioTrack: AudioTrack? + var episode: PodcastEpisode? + var completed: Bool = false + var moved: Bool = false + var failed: Bool = false + var uri: String? + var destinationUri: String? + var finalDestinationUri: String? + var downloadId: Int? + var progress: Int = 0 + var task: URLSessionDownloadTask! + + private enum CodingKeys : String, CodingKey { + case id, progress + } + + static func ignoredProperties() -> [String] { + ["task"] + } +} + +extension DownloadItemPart { + init(filename: String, destination: URL, itemTitle: String, serverPath: String, audioTrack:AudioTrack?, episode: PodcastEpisode?) { + var downloadUrl = "" // TODO: Set this + if (serverPath.hasSuffix("/cover")) { + downloadUrl += "&format=jpeg" // For cover images force to jpeg + } + } +} diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 1dcdaf6a..45f5a98b 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -145,6 +145,30 @@ class Database { } } + public func getDownloadItem(downloadItemId: String) -> DownloadItem? { + Database.realmQueue.sync { + instance.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) + } + } + + public func getDownloadItem(libraryItemId: String) -> DownloadItem? { + Database.realmQueue.sync { + instance.objects(DownloadItem.self).filter("libraryItemId == %@", libraryItemId).first + } + } + + public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? { + Database.realmQueue.sync { + instance.objects(DownloadItem.self).filter("SUBQUERY(downloadItemParts, $part, $part.id == %@) .@count > 0", downloadItemPartId).first + } + } + + public func saveDownloadItem(_ downloadItem: DownloadItem) { + Database.realmQueue.sync { + try! instance.write { instance.add(downloadItem) } + } + } + public func getDeviceSettings() -> DeviceSettings { return Database.realmQueue.sync { return instance.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() diff --git a/ios/App/Shared/util/LibraryItemDownloadSession.swift b/ios/App/Shared/util/LibraryItemDownloadSession.swift deleted file mode 100644 index 7a571117..00000000 --- a/ios/App/Shared/util/LibraryItemDownloadSession.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// LibraryItemDownloadSession.swift -// App -// -// Created by Ron Heft on 8/2/22. -// - -import Foundation - -enum LibraryItemDownloadError: String, Error { - case noTracks = "No tracks on library item" - case noMetadata = "No metadata for track, unable to download" - case failedDownload = "Failed to download item" -} - -class LibraryItemDownloadSession { - - let item: LibraryItem - - private let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - - init(_ item: LibraryItem) { - self.item = item - } - - public func startDownload() async throws -> LocalLibraryItem { - guard let tracks = item.media.tracks else { - throw LibraryItemDownloadError.noTracks - } - - return try await withThrowingTaskGroup(of: LocalFile.self, returning: LocalLibraryItem.self) { group in - for (position, track) in tracks.enumerated() { - group.addTask { try await self.startLibraryItemTrackDownload(item: self.item, position: position, track: track) } - } - - var files = [LocalFile]() - for try await file in group { - files.append(file) - } - - return LocalLibraryItem(self.item, localUrl: self.documentsDirectory, server: Store.serverConfig!, files: files) - } - } - - private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) async throws -> LocalFile { - NSLog("TRACK \(track.contentUrl!)") - - // If we don't name metadata, then we can't proceed - guard let filename = track.metadata?.filename else { - throw LibraryItemDownloadError.noMetadata - } - - let serverUrl = urlForTrack(item: item, track: track) - let itemDirectory = createLibraryItemFileDirectory(item: item) - let localUrl = itemDirectory.appendingPathComponent("\(filename)") - - try await downloadFile(serverUrl: serverUrl, localUrl: localUrl) - return LocalFile(item.id, filename, track.mimeType, localUrl) - } - - private func createLibraryItemFileDirectory(item: LibraryItem) -> URL { - let itemDirectory = documentsDirectory.appendingPathComponent("\(item.id)") - - NSLog("ITEM DIR \(itemDirectory)") - - // Create library item directory - do { - try FileManager.default.createDirectory(at: itemDirectory, withIntermediateDirectories: true) - } catch { - NSLog("Failed to CREATE LI DIRECTORY \(error)") - } - - return itemDirectory - } - - private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { - // filename needs to be encoded otherwise would just use contentUrl - let filenameEncoded = track.metadata?.filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) - let urlstr = "\(Store.serverConfig!.address)/s/item/\(item.id)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" - return URL(string: urlstr)! - } - - private func downloadFile(serverUrl: URL, localUrl: URL) async throws { - return try await withCheckedThrowingContinuation { continuation in - let downloadTask = URLSession.shared.downloadTask(with: serverUrl) { urlOrNil, responseOrNil, errorOrNil in - guard let tempUrl = urlOrNil else { - continuation.resume(throwing: errorOrNil!) - return - } - - do { - try FileManager.default.moveItem(at: tempUrl, to: localUrl) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - downloadTask.resume() - } - } - -}