diff --git a/components/tables/podcast/EpisodeRow.vue b/components/tables/podcast/EpisodeRow.vue index 0110b1fd..35d5f3ef 100644 --- a/components/tables/podcast/EpisodeRow.vue +++ b/components/tables/podcast/EpisodeRow.vue @@ -26,7 +26,7 @@ -
+
audio_file {{ downloadItem ? 'downloading' : 'download' }} download_done @@ -143,7 +143,12 @@ export default { }, downloadClick() { if (this.downloadItem) return - this.download() + if (this.isIos) { + // no local folders on iOS + this.startDownload() + } else { + this.download() + } }, async download(selectedLocalFolder = null) { var localFolder = selectedLocalFolder @@ -183,7 +188,14 @@ export default { } }, async startDownload(localFolder) { - var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id }) + var payload = { + libraryItemId: this.libraryItemId, + episodeId: this.episode.id + } + if (localFolder) { + this.localFolderId = localFolder.id + } + var downloadRes = await AbsDownloader.downloadLibraryItem(payload) if (downloadRes && downloadRes.error) { var errorMsg = downloadRes.error || 'Unknown error' console.error('Download error', errorMsg) diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 105aefab..fdc2ea46 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -34,18 +34,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func applicationDidEnterBackground(_ application: UIApplication) { // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + NSLog("Audiobookself is now in the background") } func applicationWillEnterForeground(_ application: UIApplication) { // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + NSLog("Audiobookself is now in the foreground") } func applicationDidBecomeActive(_ application: UIApplication) { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + NSLog("Audiobookself is now active") } func applicationWillTerminate(_ application: UIApplication) { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + NSLog("Audiobookself is terminating") } func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { diff --git a/ios/App/App/plugins/AbsAudioPlayer.swift b/ios/App/App/plugins/AbsAudioPlayer.swift index a66d3b36..536fd84f 100644 --- a/ios/App/App/plugins/AbsAudioPlayer.swift +++ b/ios/App/App/plugins/AbsAudioPlayer.swift @@ -21,6 +21,7 @@ public class AbsAudioPlayer: CAPPlugin { NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerSet), name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil) self.bridge?.webView?.allowsBackForwardNavigationGestures = true; @@ -44,12 +45,20 @@ public class AbsAudioPlayer: CAPPlugin { let isLocalItem = libraryItemId?.starts(with: "local_") ?? false if (isLocalItem) { - let item = Database.shared.getLocalLibraryItem(localLibraryItem: libraryItemId!) - // TODO: Logic required for podcasts here - let playbackSession = item?.getPlaybackSession(episode: nil) + let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!) + let episode = item?.getPodcastEpisode(episodeId: episodeId) + let playbackSession = item?.getPlaybackSession(episode: episode) + sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady) + do { + self.sendPlaybackSession(session: try playbackSession.asDictionary()) + call.resolve(try playbackSession.asDictionary()) + } catch(let exception) { + NSLog("failed to convert session to json") + debugPrint(exception) + call.resolve([:]) + } PlayerHandler.startPlayback(session: playbackSession!, playWhenReady: playWhenReady, playbackRate: playbackRate) self.sendMetadata() - call.resolve() } else { // Playing from the server sendPrepareMetadataEvent(itemId: libraryItemId!, playWhenReady: playWhenReady) ApiClient.startPlaybackSession(libraryItemId: libraryItemId!, episodeId: episodeId, forceTranscode: false) { session in @@ -62,12 +71,12 @@ public class AbsAudioPlayer: CAPPlugin { call.resolve([:]) } - PlayerHandler.startPlayback(session: session, playWhenReady: playWhenReady, playbackRate: playbackRate) self.sendMetadata() } } } + @objc func closePlayback(_ call: CAPPluginCall) { NSLog("Close playback") @@ -171,12 +180,21 @@ public class AbsAudioPlayer: CAPPlugin { "value": PlayerHandler.getCurrentTime() ]) } + @objc func sendSleepTimerSet() { self.notifyListeners("onSleepTimerSet", data: [ "value": PlayerHandler.remainingSleepTime ]) } + @objc func onLocalMediaProgressUpdate() { + guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return } + guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return } + guard let progressUpdate = try? localMediaProgress.asDictionary() else { return } + NSLog("Sending local progress back to the UI") + self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate) + } + @objc func onPlaybackFailed() { if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) { let playbackSession = PlayerHandler.getPlaybackSession() @@ -211,6 +229,7 @@ public class AbsAudioPlayer: CAPPlugin { "playWhenReady": playWhenReady, ]) } + @objc func sendPlaybackSession(session: [String: Any]) { self.notifyListeners("onPlaybackSession", data: session) } diff --git a/ios/App/App/plugins/AbsDatabase.m b/ios/App/App/plugins/AbsDatabase.m index 21848adf..14434572 100644 --- a/ios/App/App/plugins/AbsDatabase.m +++ b/ios/App/App/plugins/AbsDatabase.m @@ -20,6 +20,9 @@ CAP_PLUGIN(AbsDatabase, "AbsDatabase", CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(syncLocalMediaProgressWithServer, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise); ) - diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index cc940804..1a25c9b6 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -8,6 +8,7 @@ import Foundation import Capacitor import RealmSwift +import SwiftUI extension String { @@ -88,7 +89,7 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItem(_ call: CAPPluginCall) { do { - let item = Database.shared.getLocalLibraryItem(localLibraryItem: call.getString("id") ?? "") + let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "") switch item { case .some(let foundItem): call.resolve(try foundItem.asDictionary()) @@ -104,7 +105,7 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) { do { - let item = Database.shared.getLocalLibraryItemByLLId(libraryItem: call.getString("libraryItemId") ?? "") + let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "") switch item { case .some(let foundItem): call.resolve(try foundItem.asDictionary()) @@ -121,9 +122,107 @@ public class AbsDatabase: CAPPlugin { @objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) { call.resolve([ "value": [] ]) } + @objc func getAllLocalMediaProgress(_ call: CAPPluginCall) { - call.resolve([ "value": [] ]) + do { + call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ]) + } catch { + NSLog("Error while loading local media progress") + debugPrint(error) + call.resolve(["value": []]) + } } + + @objc func removeLocalMediaProgress(_ call: CAPPluginCall) { + let localMediaProgressId = call.getString("localMediaProgressId") + guard let localMediaProgressId = localMediaProgressId else { + call.reject("localMediaProgressId not specificed") + return + } + Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId) + call.resolve() + } + + @objc func syncLocalMediaProgressWithServer(_ call: CAPPluginCall) { + guard Store.serverConfig != nil else { + call.reject("syncLocalMediaProgressWithServer not connected to server") + return + } + ApiClient.syncMediaProgress { results in + do { + call.resolve(try results.asDictionary()) + } catch { + call.reject("Failed to report synced media progress") + } + } + } + + @objc func syncServerMediaProgressWithLocalMediaProgress(_ call: CAPPluginCall) { + let serverMediaProgress = call.getJson("mediaProgress", type: MediaProgress.self) + let localLibraryItemId = call.getString("localLibraryItemId") + let localEpisodeId = call.getString("localEpisodeId") + let localMediaProgressId = call.getString("localMediaProgressId") + + do { + guard let serverMediaProgress = serverMediaProgress else { + return call.reject("serverMediaProgress not specified") + } + guard localLibraryItemId != nil || localMediaProgressId != nil else { + return call.reject("localLibraryItemId or localMediaProgressId must be specified") + } + + let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) + guard var localMediaProgress = localMediaProgress else { + call.reject("Local media progress not found or created") + return + } + localMediaProgress.updateFromServerMediaProgress(serverMediaProgress) + + NSLog("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress") + Database.shared.saveLocalMediaProgress(localMediaProgress) + call.resolve(try localMediaProgress.asDictionary()) + } catch { + call.reject("Failed to sync media progress") + debugPrint(error) + } + } + + @objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) { + let localLibraryItemId = call.getString("localLibraryItemId") + let localEpisodeId = call.getString("localEpisodeId") + let localMediaProgressId = call.getString("localMediaProgressId") + let isFinished = call.getBool("isFinished", false) + + NSLog("updateLocalMediaProgressFinished \(localMediaProgressId ?? "Unknown") | Is Finished: \(isFinished)") + + let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) + guard var localMediaProgress = localMediaProgress else { + call.resolve(["error": "Library Item not found"]) + return + } + + // Update finished status + localMediaProgress.updateIsFinished(isFinished) + Database.shared.saveLocalMediaProgress(localMediaProgress) + + // Build API response + let progressDictionary = try? localMediaProgress.asDictionary() + var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""] + + // Send update to the server if logged in + let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil + let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId + if hasLinkedServer && loggedIntoServer { + response["server"] = true + let payload = ["isFinished": isFinished] + ApiClient.updateMediaProgress(libraryItemId: localMediaProgress.libraryItemId!, episodeId: localEpisodeId, payload: payload) { + call.resolve(response) + } + } else { + call.resolve(response) + } + } + @objc func updateDeviceSettings(_ call: CAPPluginCall) { let disableAutoRewind = call.getBool("disableAutoRewind") ?? false let enableAltView = call.getBool("enableAltView") ?? false diff --git a/ios/App/App/plugins/AbsDownloader.swift b/ios/App/App/plugins/AbsDownloader.swift index 0b085de1..099bfbbb 100644 --- a/ios/App/App/plugins/AbsDownloader.swift +++ b/ios/App/App/plugins/AbsDownloader.swift @@ -12,7 +12,7 @@ import RealmSwift @objc(AbsDownloader) public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { - static let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadItemPart: inout DownloadItemPart) throws -> Void @@ -23,11 +23,11 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { }() private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent) private var downloadItemProgress = [String: DownloadItem]() - private var isMonitoringProgress = false + private var monitoringProgressTimer: Timer? public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in - downloadItemPart.progress = 1 + downloadItemPart.progress = 100 downloadItemPart.completed = true do { @@ -103,47 +103,49 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates private func notifyDownloadProgress() { - if !self.isMonitoringProgress { - self.isMonitoringProgress = true - DispatchQueue.global(qos: .userInteractive).async { - NSLog("Starting monitoring download progress...") - - // Fetch active downloads in a thread-safe way - func fetchActiveDownloads() -> [String: DownloadItem]? { - self.progressStatusQueue.sync { self.downloadItemProgress } - } - - // Remove a completed download item in a thread-safe way - func handleDoneDownloadItem(_ item: DownloadItem) { - self.progressStatusQueue.async(flags: .barrier) { - self.downloadItemProgress.removeValue(forKey: item.id!) + if self.monitoringProgressTimer == nil { + NSLog("Already monitoring progress, no need to start timer again") + } else { + DispatchQueue.runOnMainQueue { + self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { t in + NSLog("Starting monitoring download progress...") + + // Fetch active downloads in a thread-safe way + func fetchActiveDownloads() -> [String: DownloadItem]? { + self.progressStatusQueue.sync { + let activeDownloads = self.downloadItemProgress + if activeDownloads.isEmpty { + NSLog("Finishing monitoring download progress...") + t.invalidate() + } + return activeDownloads + } } - Database.shared.removeDownloadItem(item) - self.handleDownloadTaskCompleteFromDownloadItem(item) - } - - // While there are active download items, emit status updates - while !(fetchActiveDownloads()?.isEmpty ?? false) { + + // Remove a completed download item in a thread-safe way + func handleDoneDownloadItem(_ item: DownloadItem) { + self.progressStatusQueue.async(flags: .barrier) { + self.downloadItemProgress.removeValue(forKey: item.id!) + } + Database.shared.removeDownloadItem(item) + self.handleDownloadTaskCompleteFromDownloadItem(item) + } + + // Emit status for active downloads if let activeDownloads = fetchActiveDownloads() { for item in activeDownloads.values { try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary()) if item.isDoneDownloading() { handleDoneDownloadItem(item) } } } - - // Wait 200ms before reporting status again - Thread.sleep(forTimeInterval: TimeInterval(0.2)) - } - - NSLog("Finished monitoring download progress...") - self.isMonitoringProgress = false + }) } } } private func handleDownloadTaskCompleteFromDownloadItem(_ downloadItem: DownloadItem) { var statusNotification = [String: Any]() - statusNotification["libraryItemId"] = downloadItem.libraryItemId + statusNotification["libraryItemId"] = downloadItem.id if ( downloadItem.didDownloadSuccessfully() ) { ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { libraryItem in @@ -155,19 +157,22 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let files = downloadItem.downloadItemParts.enumerated().compactMap { _, part -> LocalFile? in if part.filename == "cover.jpg" { coverFile = part.destinationUri - return nil - } else { - return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize)) } + return LocalFile(libraryItem.id, part.filename!, part.mimeType()!, part.destinationUri!, fileSize: Int(part.destinationURL!.fileSize)) + } + var localLibraryItem = Database.shared.getLocalLibraryItem(byServerLibraryItemId: libraryItem.id) + if (localLibraryItem != nil && localLibraryItem!.isPodcast) { + try! localLibraryItem?.addFiles(files, item: libraryItem) + } else { + localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile) } - let localLibraryItem = LocalLibraryItem(libraryItem, localUrl: localDirectory, server: Store.serverConfig!, files: files, coverPath: coverFile) - Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem) + Database.shared.saveLocalLibraryItem(localLibraryItem: localLibraryItem!) statusNotification["localLibraryItem"] = try? localLibraryItem.asDictionary() if let progress = libraryItem.userMediaProgress { - // TODO: Handle podcast - let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem, episode: nil, progress: progress) + let episode = downloadItem.media?.episodes?.first(where: { $0.id == downloadItem.episodeId }) + let localMediaProgress = LocalMediaProgress(localLibraryItem: localLibraryItem!, episode: episode, progress: progress) Database.shared.saveLocalMediaProgress(localMediaProgress) statusNotification["localMediaProgress"] = try? localMediaProgress.asDictionary() } @@ -237,7 +242,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { // Queue up everything for downloading let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!) for (i, track) in tracks.enumerated() { - downloadItem.downloadItemParts.append(try startLibraryItemTrackDownload(item: item, position: i, track: track)) + downloadItem.downloadItemParts.append(try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode)) } // Also download the cover @@ -256,7 +261,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { } } - private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack) throws -> DownloadItemPart { + private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPart { NSLog("TRACK \(track.contentUrl!)") // If we don't name metadata, then we can't proceed @@ -269,7 +274,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let localUrl = "\(itemDirectory)/\(filename)" let task = session.downloadTask(with: serverUrl) - let downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: nil) + var downloadItemPart = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode) // Store the id on the task so the download item can be pulled from the database later task.taskDescription = downloadItemPart.id @@ -305,16 +310,34 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate { let itemDirectory = item.id NSLog("ITEM DIR \(itemDirectory)") - do { - try FileManager.default.createDirectory(at: AbsDownloader.downloadsDirectory.appendingPathComponent(itemDirectory), withIntermediateDirectories: true) - } catch { - NSLog("Failed to CREATE LI DIRECTORY \(error)") + guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else { + NSLog("Failed to CREATE LI DIRECTORY \(itemDirectory)") throw LibraryItemDownloadError.failedDirectory } return itemDirectory } + static func itemDownloadFolder(path: String) -> URL? { + do { + var itemFolder = AbsDownloader.downloadsDirectory.appendingPathComponent(path) + + if !FileManager.default.fileExists(atPath: itemFolder.path) { + try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: true) + } + + // Make sure we don't backup download files to iCloud + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try itemFolder.setResourceValues(resourceValues) + + return itemFolder + } catch { + NSLog("Failed to CREATE LI DIRECTORY \(error)") + return nil + } + } + } enum LibraryItemDownloadError: String, Error { @@ -322,6 +345,7 @@ enum LibraryItemDownloadError: String, Error { case noMetadata = "No metadata for track, unable to download" case libraryItemNotPodcast = "Library item is not a podcast but episode was requested" case podcastEpisodeNotFound = "Invalid podcast episode not found" + case podcastOnlySupported = "Only podcasts are supported for this function" case unknownMediaType = "Unknown media type" case failedDirectory = "Failed to create directory" case failedDownload = "Failed to download item" diff --git a/ios/App/App/plugins/AbsFileSystem.swift b/ios/App/App/plugins/AbsFileSystem.swift index c2d59580..b64906b5 100644 --- a/ios/App/App/plugins/AbsFileSystem.swift +++ b/ios/App/App/plugins/AbsFileSystem.swift @@ -13,79 +13,102 @@ public class AbsFileSystem: CAPPlugin { @objc func selectFolder(_ call: CAPPluginCall) { let mediaType = call.getString("mediaType") - // TODO: Implement NSLog("Select Folder for media type \(mediaType ?? "UNSET")") - call.resolve() + call.unavailable("Not available on iOS") } @objc func checkFolderPermission(_ call: CAPPluginCall) { let folderUrl = call.getString("folderUrl") - // TODO: Is this even necessary on iOS? NSLog("checkFolderPermission for folder \(folderUrl ?? "UNSET")") - call.resolve([ - "value": true - ]) + call.unavailable("Not available on iOS") } @objc func scanFolder(_ call: CAPPluginCall) { let folderId = call.getString("folderId") let forceAudioProbe = call.getBool("forceAudioProbe", false) - // TODO: Implement NSLog("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)") - call.resolve() + call.unavailable("Not available on iOS") } @objc func removeFolder(_ call: CAPPluginCall) { let folderId = call.getString("folderId") - // TODO: Implement NSLog("removeFolder \(folderId ?? "UNSET")") - call.resolve() + call.unavailable("Not available on iOS") } @objc func removeLocalLibraryItem(_ call: CAPPluginCall) { let localLibraryItemId = call.getString("localLibraryItemId") - // TODO: Implement NSLog("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")") - call.resolve() + call.unavailable("Not available on iOS") } @objc func scanLocalLibraryItem(_ call: CAPPluginCall) { let localLibraryItemId = call.getString("localLibraryItemId") let forceAudioProbe = call.getBool("forceAudioProbe", false) - // TODO: Implement NSLog("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)") - call.resolve() + call.unavailable("Not available on iOS") } @objc func deleteItem(_ call: CAPPluginCall) { - let localLibraryItemId = call.getString("localLibraryItemId") + let localLibraryItemId = call.getString("id") let contentUrl = call.getString("contentUrl") - - // TODO: Implement + NSLog("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")") - call.resolve() + var success = false + do { + if let localLibraryItemId = localLibraryItemId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { + try FileManager.default.removeItem(at: item.contentDirectory!) + Database.shared.removeLocalLibraryItem(localLibraryItemId: localLibraryItemId) + success = true + } + } catch { + NSLog("Failed to delete \(error)") + success = false + } + + call.resolve(["success": success]) } @objc func deleteTrackFromItem(_ call: CAPPluginCall) { - let localLibraryItemId = call.getString("localLibraryItemId") + let localLibraryItemId = call.getString("id") let trackLocalFileId = call.getString("trackLocalFileId") - let contentUrl = call.getString("contentUrl") - // TODO: Implement - NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET") url \(contentUrl ?? "UNSET")") + NSLog("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")") - call.resolve() + var success = false + do { + if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, var item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { + if let fileIndex = item.localFiles.firstIndex(where: { $0.id == trackLocalFileId }) { + try FileManager.default.removeItem(at: item.localFiles[fileIndex].contentPath) + item.localFiles.remove(at: fileIndex) + if item.isPodcast, var media = item.media { + media.episodes = media.episodes?.filter { $0.audioTrack?.localFileId != trackLocalFileId } + item.media = media + } + Database.shared.saveLocalLibraryItem(localLibraryItem: item) + call.resolve(try item.asDictionary()) + success = true + } + } + } catch { + NSLog("Failed to delete \(error)") + success = false + } + + if !success { + call.resolve(["success": success]) + } } } diff --git a/ios/App/Shared/models/DataClasses.swift b/ios/App/Shared/models/DataClasses.swift index ff5d1211..6f5ceea4 100644 --- a/ios/App/Shared/models/DataClasses.swift +++ b/ios/App/Shared/models/DataClasses.swift @@ -264,7 +264,7 @@ class PodcastEpisode: Object, Codable { @Persisted var audioTrack: AudioTrack? @Persisted var duration: Double = 0 @Persisted var size: Int = 0 -// var serverEpisodeId: String? + var serverEpisodeId: String { self.id } private enum CodingKeys : String, CodingKey { case id, @@ -277,14 +277,44 @@ class PodcastEpisode: Object, Codable { audioFile, audioTrack, duration, - size + size, + serverEpisodeId } - // TODO: Encoding + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + index = try? values.decode(Int.self, forKey: .index) + episode = try? values.decode(String.self, forKey: .episode) + episodeType = try? values.decode(String.self, forKey: .episodeType) + title = try values.decode(String.self, forKey: .title) + subtitle = try? values.decode(String.self, forKey: .subtitle) + desc = try? values.decode(String.self, forKey: .desc) + audioFile = try? values.decode(AudioFile.self, forKey: .audioFile) + audioTrack = try? values.decode(AudioTrack.self, forKey: .audioTrack) + duration = try? values.decode(Double.self, forKey: .duration) + size = try? values.decode(Int.self, forKey: .size) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(index, forKey: .index) + try container.encode(episode, forKey: .episode) + try container.encode(episodeType, forKey: .episodeType) + try container.encode(title, forKey: .title) + try container.encode(subtitle, forKey: .subtitle) + try container.encode(desc, forKey: .desc) + try container.encode(audioFile, forKey: .audioFile) + try container.encode(audioTrack, forKey: .audioTrack) + try container.encode(duration, forKey: .duration) + try container.encode(size, forKey: .size) + try container.encode(serverEpisodeId, forKey: .serverEpisodeId) + } } class AudioFile: Object, Codable { - @Persisted var index: Int = 0 + @Persisted var index: Int? @Persisted var ino: String = "" @Persisted var metadata: FileMetadata? @@ -299,7 +329,7 @@ class AudioFile: Object, Codable { required init(from decoder: Decoder) throws { super.init() let values = try decoder.container(keyedBy: CodingKeys.self) - index = try values.decode(Int.self, forKey: .index) + index = try? values.decode(Int.self, forKey: .index) ino = try values.decode(String.self, forKey: .ino) metadata = try? values.decode(FileMetadata.self, forKey: .metadata) } @@ -339,6 +369,9 @@ class Author: Object, Codable { try container.encode(name, forKey: .name) try container.encode(coverPath, forKey: .coverPath) } + + override init() { + super.init() } class Chapter: Object, Codable { @@ -381,8 +414,7 @@ class AudioTrack: Object, Codable { @Persisted var contentUrl: String? @Persisted var mimeType: String = "" @Persisted var metadata: FileMetadata? - // var isLocal: Bool - // var localFileId: String? + var localFileId: String? // var audioProbeResult: AudioProbeResult? Needed for local playback @Persisted var serverIndex: Int? @@ -418,6 +450,20 @@ class AudioTrack: Object, Codable { try container.encode(metadata, forKey: .metadata) try container.encode(serverIndex, forKey: .serverIndex) } + + mutating func setLocalInfo(filenameIdMap: [String: String], serverIndex: Int) -> Bool { + if let localFileId = filenameIdMap[self.metadata?.filename ?? ""] { + self.localFileId = localFileId + self.serverIndex = serverIndex + return true + } + return false + } + + func getLocalFile() -> LocalFile? { + guard let localFileId = self.localFileId else { return nil } + return Database.shared.getLocalFile(localFileId: localFileId) + } } class FileMetadata: Object, Codable { @@ -562,18 +608,17 @@ class MediaProgress: Object, Codable { } required init(from decoder: Decoder) throws { - super.init() let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) libraryItemId = try values.decode(String.self, forKey: .libraryItemId) episodeId = try? values.decode(String.self, forKey: .episodeId) - duration = try values.decode(Double.self, forKey: .duration) - progress = try values.decode(Double.self, forKey: .progress) - currentTime = try values.decode(Double.self, forKey: .currentTime) + duration = try values.doubleOrStringDecoder(key: .duration) + progress = try values.doubleOrStringDecoder(key: .progress) + currentTime = try values.doubleOrStringDecoder(key: .currentTime) isFinished = try values.decode(Bool.self, forKey: .isFinished) - lastUpdate = try values.decode(Int.self, forKey: .lastUpdate) - startedAt = try values.decode(Int.self, forKey: .startedAt) - finishedAt = try? values.decode(Int.self, forKey: .finishedAt) + lastUpdate = try values.intOrStringDecoder(key: .lastUpdate) + startedAt = try values.intOrStringDecoder(key: .startedAt) + finishedAt = try? values.intOrStringDecoder(key: .finishedAt) } func encode(to encoder: Encoder) throws { diff --git a/ios/App/Shared/models/DownloadItem.swift b/ios/App/Shared/models/DownloadItem.swift index 9e4eb221..aee00561 100644 --- a/ios/App/Shared/models/DownloadItem.swift +++ b/ios/App/Shared/models/DownloadItem.swift @@ -73,6 +73,35 @@ class DownloadItemPart: Object, Codable { @Persisted var uri: String? @Persisted var destinationUri: String? @Persisted var progress: Double = 0 + var task: URLSessionDownloadTask? + +struct DownloadItemPart: Realmable, Codable { + var id: String = UUID().uuidString + var filename: 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 downloadURL: URL? { + if let uri = self.uri { + return URL(string: uri) + } else { + return nil + } + } + var destinationUri: String? + var destinationURL: URL? { + if let destinationUri = self.destinationUri { + return AbsDownloader.itemDownloadFolder(path: destinationUri)! + } else { + return nil + } + } + var progress: Double = 0 var task: URLSessionDownloadTask! private enum CodingKeys : String, CodingKey { @@ -105,3 +134,36 @@ class DownloadItemPart: Object, Codable { try container.encode(progress, forKey: .progress) } } + +extension DownloadItemPart { + init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) { + self.filename = filename + self.itemTitle = itemTitle + self.serverPath = serverPath + self.audioTrack = audioTrack + self.episode = episode + + let config = Store.serverConfig! + var downloadUrl = "" + if (serverPath.hasSuffix("/cover")) { + downloadUrl += "\(config.address)\(serverPath)?token=\(config.token)" + downloadUrl += "&format=jpeg" // For cover images force to jpeg + } else { + downloadUrl = destination + } + self.uri = downloadUrl + self.destinationUri = destination + } + + func mimeType() -> String? { + if let track = audioTrack { + return track.mimeType + } else if let podcastTrack = episode?.audioTrack { + return podcastTrack.mimeType + } else if serverPath?.hasSuffix("/cover") ?? false { + return "image/jpg" + } else { + return nil + } + } +} diff --git a/ios/App/Shared/models/LocalLibrary.swift b/ios/App/Shared/models/LocalLibrary.swift index 8ffd6a12..a2e6acf1 100644 --- a/ios/App/Shared/models/LocalLibrary.swift +++ b/ios/App/Shared/models/LocalLibrary.swift @@ -22,6 +22,33 @@ class LocalLibraryItem: Object, Codable { @Persisted var serverAddress: String? @Persisted var serverUserId: String? @Persisted(indexed: true) var libraryItemId: String? + + var contentUrl: String? { + if let path = _contentUrl { + return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString + } else { + return nil + } + } + + var contentDirectory: URL? { + if let path = _contentUrl { + return AbsDownloader.itemDownloadFolder(path: path) + } else { + return nil + } + } + + var coverContentUrl: String? { + if let path = self._coverContentUrl { + return AbsDownloader.itemDownloadFolder(path: path)!.absoluteString + } else { + return nil + } + } + + var isBook: Bool { self.mediaType == "book" } + var isPodcast: Bool { self.mediaType == "podcast" } private enum CodingKeys : String, CodingKey { case id, basePath, contentUrl, isInvalid, mediaType, media, localFiles, coverContentUrl, isLocal, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId @@ -37,14 +64,12 @@ class LocalLibraryItem: Object, Codable { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) basePath = try values.decode(String.self, forKey: .basePath) - contentUrl = try values.decode(String.self, forKey: .contentUrl) isInvalid = try values.decode(Bool.self, forKey: .isInvalid) mediaType = try values.decode(String.self, forKey: .mediaType) media = try? values.decode(MediaType.self, forKey: .media) if let files = try? values.decode([LocalFile].self, forKey: .localFiles) { localFiles.append(objectsIn: files) } - _coverContentUrl = try values.decode(String.self, forKey: .coverContentUrl) isLocal = try values.decode(Bool.self, forKey: .isLocal) serverConnectionConfigId = try? values.decode(String.self, forKey: .serverConnectionConfigId) serverAddress = try? values.decode(String.self, forKey: .serverAddress) @@ -111,9 +136,13 @@ class LocalFile: Object, Codable { @Persisted var contentUrl: String = "" @Persisted var mimeType: String? @Persisted var size: Int = 0 + + var contentUrl: String { AbsDownloader.itemDownloadFolder(path: _contentUrl)!.absoluteString } + var contentPath: URL { AbsDownloader.itemDownloadFolder(path: _contentUrl)! } + var basePath: String? { self.filename } private enum CodingKeys : String, CodingKey { - case id, filename, contentUrl, absolutePath, mimeType, size + case id, filename, contentUrl, mimeType, size, basePath } override init() { @@ -124,7 +153,6 @@ class LocalFile: Object, Codable { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) filename = try? values.decode(String.self, forKey: .filename) - contentUrl = try values.decode(String.self, forKey: .contentUrl) mimeType = try? values.decode(String.self, forKey: .mimeType) size = try values.decode(Int.self, forKey: .size) } @@ -134,9 +162,9 @@ class LocalFile: Object, Codable { try container.encode(id, forKey: .id) try container.encode(filename, forKey: .filename) try container.encode(contentUrl, forKey: .contentUrl) - try container.encode(absolutePath, forKey: .absolutePath) try container.encode(mimeType, forKey: .mimeType) try container.encode(size, forKey: .size) + try container.encode(basePath, forKey: .basePath) } } @@ -157,6 +185,8 @@ class LocalMediaProgress: Object, Codable { @Persisted var serverUserId: String? @Persisted(indexed: true) var libraryItemId: String? @Persisted(indexed: true) var episodeId: String? + + var progressPercent: Int { Int(self.progress * 100) } private enum CodingKeys : String, CodingKey { case id, localLibraryItemId, localEpisodeId, duration, progress, currentTime, isFinished, lastUpdate, startedAt, finishedAt, serverConnectionConfigId, serverAddress, serverUserId, libraryItemId, episodeId diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 68dfdf76..e5bfed64 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -13,39 +13,44 @@ extension LocalLibraryItem { self.contentUrl = localUrl self.mediaType = item.mediaType - self.media = item.media self.localFiles.append(objectsIn: files) - self.coverContentUrl = coverPath + self._coverContentUrl = coverPath self.libraryItemId = item.id self.serverConnectionConfigId = server.id self.serverAddress = server.address self.serverUserId = server.userId + + // Link the audio tracks and files + linkLocalFiles(files, fromMedia: item.media) } - var contentUrl: String? { - set(url) { - _contentUrl = url - } - get { - if let path = _contentUrl { - return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString - } else { - return nil - } - } + mutating func addFiles(_ files: [LocalFile], item: LibraryItem) throws { + guard self.isPodcast else { throw LibraryItemDownloadError.podcastOnlySupported } + self.localFiles.append(contentsOf: files.filter({ $0.isAudioFile() })) + linkLocalFiles(self.localFiles, fromMedia: item.media) } - var coverContentUrl: String? { - set(url) { - _coverContentUrl = url - } - get { - if let path = self._coverContentUrl { - return AbsDownloader.downloadsDirectory.appendingPathComponent(path).absoluteString - } else { - return nil + mutating private func linkLocalFiles(_ files: [LocalFile], fromMedia: MediaType) { + var fromMedia = fromMedia + let fileMap = files.map { ($0.filename ?? "", $0.id) } + let fileIdByFilename = Dictionary(fileMap, uniquingKeysWith: { (_, last) in last }) + if ( self.isBook ) { + if let tracks = fromMedia.tracks { + for i in tracks.indices { + _ = fromMedia.tracks?[i].setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: i) + } + } + } else if ( self.isPodcast ) { + if let episodes = fromMedia.episodes { + fromMedia.episodes = episodes.compactMap { episode in + // Filter out episodes not downloaded + var episode = episode + let episodeIsDownloaded = episode.audioTrack?.setLocalInfo(filenameIdMap: fileIdByFilename, serverIndex: 0) ?? false + return episodeIsDownloaded ? episode : nil + } } } + self.media = fromMedia } func getDuration() -> Double { @@ -54,7 +59,13 @@ extension LocalLibraryItem { return total } - func getPlaybackSession(episode: LocalPodcastEpisode?) -> PlaybackSession { + func getPodcastEpisode(episodeId: String?) -> PodcastEpisode? { + guard self.isPodcast else { return nil } + guard let episodes = self.media?.episodes else { return nil } + return episodes.first(where: { $0.id == episodeId }) + } + + func getPlaybackSession(episode: PodcastEpisode?) -> PlaybackSession { let localEpisodeId = episode?.id let sessionId = "play_local_\(UUID().uuidString)" @@ -62,13 +73,13 @@ extension LocalLibraryItem { let mediaProgressId = (localEpisodeId != nil) ? "\(self.id)-\(localEpisodeId!)" : self.id let mediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: mediaProgressId) - // TODO: Clean up add mediaType methods for displayTitle and displayAuthor let mediaMetadata = self.media?.metadata - let audioTracks = self.media?.tracks - let authorName = mediaMetadata?.authorName + let chapters = self.media?.chapters + var audioTracks = self.media?.tracks + let authorName = mediaMetadata?.authorDisplayName - if let episode = episode { - // TODO: Implement podcast + if let episode = episode, let track = episode.audioTrack { + audioTracks = [track] } let dateNow = Date().timeIntervalSince1970 @@ -78,18 +89,19 @@ extension LocalLibraryItem { libraryItemId: self.libraryItemId, episodeId: episode?.serverEpisodeId, mediaType: self.mediaType, - chapters: [], + chapters: chapters ?? [], displayTitle: mediaMetadata?.title, displayAuthor: authorName, - coverPath: nil, + coverPath: self.coverContentUrl, duration: self.getDuration(), - playMethod: 3, + playMethod: PlayMethod.local.rawValue, startedAt: dateNow, updatedAt: 0, timeListening: 0.0, - audioTracks: [], + audioTracks: audioTracks ?? [], currentTime: mediaProgress?.currentTime ?? 0.0, libraryItem: nil, + localLibraryItem: self, serverConnectionConfigId: self.serverConnectionConfigId, serverAddress: self.serverAddress ) @@ -103,7 +115,7 @@ extension LocalFile { self.id = "\(libraryItemId)_\(filename.toBase64())" self.filename = filename self.mimeType = mimeType - self.contentUrl = localUrl + self._contentUrl = localUrl self.size = fileSize } @@ -123,27 +135,84 @@ extension LocalFile { } extension LocalMediaProgress { - convenience init(localLibraryItem: LocalLibraryItem, episode: LocalPodcastEpisode?, progress: MediaProgress) { + convenience init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) { self.init() self.id = localLibraryItem.id self.localLibraryItemId = localLibraryItem.id self.libraryItemId = localLibraryItem.libraryItemId - if let episode = episode { - self.id += "-\(episode.id)" - self.episodeId = episode.id - } - self.serverAddress = localLibraryItem.serverAddress self.serverUserId = localLibraryItem.serverUserId self.serverConnectionConfigId = localLibraryItem.serverConnectionConfigId - self.duration = progress.duration - self.currentTime = progress.currentTime + self.duration = localLibraryItem.getDuration() + self.progress = 0.0 + self.currentTime = 0.0 self.isFinished = false + self.lastUpdate = Int(Date().timeIntervalSince1970) + self.startedAt = 0 + self.finishedAt = nil + + if let episode = episode { + self.id += "-\(episode.id)" + self.episodeId = episode.id + self.duration = episode.duration ?? 0.0 + } + } + + init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) { + self.init(localLibraryItem: localLibraryItem, episode: episode) + self.duration = progress.duration + self.progress = progress.progress + self.currentTime = progress.currentTime + self.isFinished = progress.isFinished self.lastUpdate = progress.lastUpdate self.startedAt = progress.startedAt self.finishedAt = progress.finishedAt } + + mutating func updateIsFinished(_ finished: Bool) { + if self.isFinished != finished { + self.progress = finished ? 1.0 : 0.0 + } + + if self.startedAt == 0 && finished { + self.startedAt = Int(Date().timeIntervalSince1970) + } + + self.isFinished = finished + self.lastUpdate = Int(Date().timeIntervalSince1970) + self.finishedAt = finished ? lastUpdate : nil + } + + mutating func updateFromPlaybackSession(_ playbackSession: PlaybackSession) { + self.currentTime = playbackSession.currentTime + self.progress = playbackSession.progress + self.lastUpdate = Int(Date().timeIntervalSince1970) + self.isFinished = playbackSession.progress >= 100.0 + self.finishedAt = self.isFinished ? self.lastUpdate : nil + } + + mutating func updateFromServerMediaProgress(_ serverMediaProgress: MediaProgress) { + self.isFinished = serverMediaProgress.isFinished + self.progress = serverMediaProgress.progress + self.currentTime = serverMediaProgress.currentTime + self.duration = serverMediaProgress.duration + self.lastUpdate = serverMediaProgress.lastUpdate + self.finishedAt = serverMediaProgress.finishedAt + self.startedAt = serverMediaProgress.startedAt + } + + static func fetchOrCreateLocalMediaProgress(localMediaProgressId: String?, localLibraryItemId: String?, localEpisodeId: String?) -> LocalMediaProgress? { + if let localMediaProgressId = localMediaProgressId { + return Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) + } else if let localLibraryItemId = localLibraryItemId { + guard let localLibraryItem = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) else { return nil } + let episode = localLibraryItem.getPodcastEpisode(episodeId: localEpisodeId) + return LocalMediaProgress(localLibraryItem: localLibraryItem, episode: episode) + } else { + return nil + } + } } diff --git a/ios/App/Shared/models/PlaybackSession.swift b/ios/App/Shared/models/PlaybackSession.swift index d5b75293..ae9dfb56 100644 --- a/ios/App/Shared/models/PlaybackSession.swift +++ b/ios/App/Shared/models/PlaybackSession.swift @@ -7,7 +7,7 @@ import Foundation -struct PlaybackSession: Decodable, Encodable { +struct PlaybackSession: Codable { var id: String var userId: String? var libraryItemId: String? @@ -26,7 +26,25 @@ struct PlaybackSession: Decodable, Encodable { var audioTracks: [AudioTrack] var currentTime: Double var libraryItem: LibraryItem? - //var localLibraryItem: LocalLibraryItem? + var localLibraryItem: LocalLibraryItem? var serverConnectionConfigId: String? var serverAddress: String? + + var isLocal: Bool { self.localLibraryItem != nil } + + var localMediaProgressId: String { + if let episodeId = episodeId { + return "\(localLibraryItem!.id)-\(episodeId)" + } else { + return localLibraryItem!.id + } + } + + var totalDuration: Double { + var total = 0.0 + self.audioTracks.forEach { total += $0.duration } + return total + } + + var progress: Double { self.currentTime / self.totalDuration } } diff --git a/ios/App/Shared/player/AudioPlayer.swift b/ios/App/Shared/player/AudioPlayer.swift index b6f8a7da..61398f2a 100644 --- a/ios/App/Shared/player/AudioPlayer.swift +++ b/ios/App/Shared/player/AudioPlayer.swift @@ -267,6 +267,7 @@ class AudioPlayer: NSObject { let startOffset = audioTrack.startOffset ?? 0.0 return startOffset + currentTrackTime } + public func getPlayMethod() -> Int { return self.playbackSession.playMethod } @@ -286,6 +287,17 @@ class AudioPlayer: NSObject { let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" let url = URL(string: urlstr)! return AVURLAsset(url: url) + } else if (playbackSession.playMethod == PlayMethod.local.rawValue) { + guard let localFile = track.getLocalFile() else { + // Worst case we can stream the file + NSLog("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")") + let filename = track.metadata?.filename ?? "" + let filenameEncoded = filename.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) + let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filenameEncoded ?? "")?token=\(Store.serverConfig!.token)" + let url = URL(string: urlstr)! + return AVURLAsset(url: url) + } + return AVURLAsset(url: localFile.contentPath) } else { // HLS Transcode let headers: [String: String] = [ "Authorization": "Bearer \(Store.serverConfig!.token)" diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index 3384e5fa..a3030d29 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -11,7 +11,7 @@ class PlayerHandler { private static var player: AudioPlayer? private static var session: PlaybackSession? private static var timer: Timer? - private static var lastSyncTime:Double = 0.0 + private static var lastSyncTime: Double = 0.0 private static var _remainingSleepTime: Int? = nil public static var remainingSleepTime: Int? { @@ -52,6 +52,22 @@ class PlayerHandler { } } + public static func startTickTimer() { + DispatchQueue.runOnMainQueue { + NSLog("Starting the tick timer") + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + self.tick() + } + } + } + + public static func stopTickTimer() { + NSLog("Stopping the tick timer") + timer?.invalidate() + timer = nil + } + public static func startPlayback(session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) { if player != nil { player?.destroy() @@ -63,31 +79,34 @@ class PlayerHandler { self.session = session player = AudioPlayer(playbackSession: session, playWhenReady: playWhenReady, playbackRate: playbackRate) - DispatchQueue.runOnMainQueue { - timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in - self.tick() - } - } + startTickTimer() } + public static func stopPlayback() { + // Pause playback first, so we can sync our current progress + player?.pause() + + // Stop updating progress before we destory the player, so we don't receive bad data + stopTickTimer() + player?.destroy() player = nil - timer?.invalidate() - timer = nil - NowPlayingInfo.shared.reset() } public static func getCurrentTime() -> Double? { self.player?.getCurrentTime() } + public static func setPlaybackSpeed(speed: Float) { self.player?.setPlaybackRate(speed) } + public static func getPlayMethod() -> Int? { self.player?.getPlayMethod() } + public static func getPlaybackSession() -> PlaybackSession? { self.player?.getPlaybackSession() } @@ -100,6 +119,7 @@ class PlayerHandler { let destinationTime = player.getCurrentTime() + amount player.seek(destinationTime, from: "handler") } + public static func seekBackward(amount: Double) { guard let player = player else { return @@ -108,9 +128,11 @@ class PlayerHandler { let destinationTime = player.getCurrentTime() - amount player.seek(destinationTime, from: "handler") } + public static func seek(amount: Double) { player?.seek(amount, from: "handler") } + public static func getMetdata() -> [String: Any] { DispatchQueue.main.async { syncProgress() @@ -140,15 +162,15 @@ class PlayerHandler { remainingSleepTime! -= 1 } } + public static func syncProgress() { if session == nil { return } guard let player = player else { return } + // Prevent a sync at the current time let playerCurrentTime = player.getCurrentTime() - if (lastSyncReport != nil && lastSyncReport?.currentTime == playerCurrentTime) { - // No need to syncProgress - return - } + let hasSyncAtCurrentTime = lastSyncReport?.currentTime.isEqual(to: playerCurrentTime) ?? false + if hasSyncAtCurrentTime { return } // Prevent multiple sync requests let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime @@ -157,16 +179,51 @@ class PlayerHandler { return } - lastSyncTime = Date().timeIntervalSince1970 // seconds + // Prevent a sync if we got junk data from the player (occurs when exiting out of memory + guard !playerCurrentTime.isNaN else { return } + lastSyncTime = Date().timeIntervalSince1970 // seconds let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync) session!.currentTime = playerCurrentTime listeningTimePassedSinceLastSync = 0 lastSyncReport = report - // TODO: check if online - NSLog("sending playback report") - ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id) + let sessionIsLocal = session!.isLocal + if !sessionIsLocal { + if Connectivity.isConnectedToInternet { + NSLog("sending playback report") + ApiClient.reportPlaybackProgress(report: report, sessionId: session!.id) + } + } else { + if let localMediaProgress = syncLocalProgress() { + if Connectivity.isConnectedToInternet { + ApiClient.reportLocalMediaProgress(localMediaProgress) { success in + NSLog("Synced local media progress: \(success)") + } + } + } + } + } + + private static func syncLocalProgress() -> LocalMediaProgress? { + guard let session = session else { return nil } + + let localMediaProgress = LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId) + guard var localMediaProgress = localMediaProgress else { + // Local media progress should have been created + // If we're here, it means a library id is invalid + return nil + } + + localMediaProgress.updateFromPlaybackSession(session) + Database.shared.saveLocalMediaProgress(localMediaProgress) + + NSLog("Local progress saved to the database") + + // Send the local progress back to front-end + NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil) + + return localMediaProgress } } diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index c9ed9e97..7addc065 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -37,7 +37,29 @@ class ApiClient { } } } - public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) { + + public static func postResource(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) { + if (Store.serverConfig == nil) { + NSLog("Server config not set") + return + } + + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(Store.serverConfig!.token)" + ] + + AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).responseDecodable(of: decodable) { response in + switch response.result { + case .success(let obj): + callback?(obj) + case .failure(let error): + NSLog("api request to \(endpoint) failed") + print(error) + } + } + } + + public static func postResource(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) { if (Store.serverConfig == nil) { NSLog("Server config not set") callback?(false) @@ -50,7 +72,7 @@ class ApiClient { AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in switch response.result { - case .success(let _): + case .success(_): callback?(true) case .failure(let error): NSLog("api request to \(endpoint) failed") @@ -60,6 +82,30 @@ class ApiClient { } } } + + public static func patchResource(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) { + if (Store.serverConfig == nil) { + NSLog("Server config not set") + callback?(false) + return + } + + let headers: HTTPHeaders = [ + "Authorization": "Bearer \(Store.serverConfig!.token)" + ] + + AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers).response { response in + switch response.result { + case .success(_): + callback?(true) + case .failure(let error): + NSLog("api request to \(endpoint) failed") + print(error) + callback?(false) + } + } + } + public static func getResource(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) { if (Store.serverConfig == nil) { NSLog("Server config not set") @@ -96,7 +142,7 @@ class ApiClient { } } - ApiClient.postResource(endpoint: endpoint, parameters: [ + let parameters: [String: Any] = [ "forceDirectPlay": !forceTranscode ? "1" : "", "forceTranscode": forceTranscode ? "1" : "", "mediaPlayer": "AVPlayer", @@ -105,7 +151,8 @@ class ApiClient { "model": modelCode, "clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ] - ], decodable: PlaybackSession.self) { obj in + ] + ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in var session = obj session.serverConnectionConfigId = Store.serverConfig!.id @@ -119,6 +166,44 @@ class ApiClient { try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil) } + public static func reportLocalMediaProgress(_ localMediaProgress: LocalMediaProgress, callback: @escaping (_ success: Bool) -> Void) { + postResource(endpoint: "api/session/local", parameters: localMediaProgress, callback: callback) + } + + public static func syncMediaProgress(callback: @escaping (_ results: LocalMediaProgressSyncResultsPayload) -> Void) { + let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter { + $0.serverConnectionConfigId == Store.serverConfig?.id + } + + if ( !localMediaProgressList.isEmpty ) { + let payload = LocalMediaProgressSyncPayload(localMediaProgress: localMediaProgressList) + NSLog("Sending sync local progress request with \(localMediaProgressList.count) progress items") + postResource(endpoint: "api/me/sync-local-progress", parameters: payload, decodable: MediaProgressSyncResponsePayload.self) { response in + let resultsPayload = LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: localMediaProgressList.count, numServerProgressUpdates: response.numServerProgressUpdates, numLocalProgressUpdates: response.localProgressUpdates?.count) + NSLog("Media Progress Sync | \(String(describing: try? resultsPayload.asDictionary()))") + + if let updates = response.localProgressUpdates { + for update in updates { + Database.shared.saveLocalMediaProgress(update) + } + } + + callback(resultsPayload) + } + } else { + NSLog("No local media progress to sync") + callback(LocalMediaProgressSyncResultsPayload(numLocalMediaProgressForServer: 0, numServerProgressUpdates: 0, numLocalProgressUpdates: 0)) + } + } + + public static func updateMediaProgress(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) { + NSLog("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)") + let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")" + patchResource(endpoint: endpoint, parameters: payload) { success in + callback() + } + } + public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) { var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress" if episodeId != nil { @@ -130,3 +215,35 @@ class ApiClient { } } } + +struct LocalMediaProgressSyncPayload: Codable { + var localMediaProgress: [LocalMediaProgress] +} + +struct MediaProgressSyncResponsePayload: Decodable { + var numServerProgressUpdates: Int? + var localProgressUpdates: [LocalMediaProgress]? + + private enum CodingKeys : String, CodingKey { + case numServerProgressUpdates, localProgressUpdates + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + numServerProgressUpdates = try? values.intOrStringDecoder(key: .numServerProgressUpdates) + localProgressUpdates = try? values.decode([LocalMediaProgress].self, forKey: .localProgressUpdates) + } +} + +struct LocalMediaProgressSyncResultsPayload: Codable { + var numLocalMediaProgressForServer: Int? + var numServerProgressUpdates: Int? + var numLocalProgressUpdates: Int? +} + +struct Connectivity { + static private let sharedInstance = NetworkReachabilityManager()! + static var isConnectedToInternet:Bool { + return self.sharedInstance.isReachable + } +} diff --git a/ios/App/Shared/util/Database.swift b/ios/App/Shared/util/Database.swift index 52533ef4..3787aef7 100644 --- a/ios/App/Shared/util/Database.swift +++ b/ios/App/Shared/util/Database.swift @@ -116,14 +116,14 @@ class Database { return Array(realm.objects(LocalLibraryItem.self)) } - public func getLocalLibraryItemByLLId(libraryItem: String) -> LocalLibraryItem? { + public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? { let realm = try! Realm() - return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == libraryItem }) + return realm.objects(LocalLibraryItem.self).first(where: { $0.libraryItemId == byServerLibraryItemId }) } - public func getLocalLibraryItem(localLibraryItem: String) -> LocalLibraryItem? { + public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? { let realm = try! Realm() - return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItem) + return realm.object(ofType: LocalLibraryItem.self, forPrimaryKey: localLibraryItemId) } public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) { @@ -131,6 +131,19 @@ class Database { try! realm.write { realm.add(localLibraryItem, update: .modified) } } + public func removeLocalLibraryItem(localLibraryItemId: String) { + let realm = try! Realm() + try! realm.write { + let item = getLocalLibraryItem(localLibraryItemId: localLibraryItemId) + realm.delete(item!) + } + } + + public func getLocalFile(localFileId: String) -> LocalFile? { + let realm = try! Realm() + return realm.object(ofType: LocalFile.self, forPrimaryKey: localFileId) + } + public func getDownloadItem(downloadItemId: String) -> DownloadItem? { let realm = try! Realm() return realm.object(ofType: DownloadItem.self, forPrimaryKey: downloadItemId) @@ -166,17 +179,14 @@ class Database { return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings() } - public func removeLocalLibraryItem(localLibraryItemId: String) { + public func getAllLocalMediaProgress() -> [LocalMediaProgress] { let realm = try! Realm() - try! realm.write { - let item = getLocalLibraryItemByLLId(libraryItem: localLibraryItemId) - realm.delete(item!) - } + return Array(realm.objects(LocalMediaProgress.self)) } public func saveLocalMediaProgress(_ mediaProgress: LocalMediaProgress) { let realm = try! Realm() - try! realm.write { realm.add(mediaProgress) } + try! realm.write { realm.add(mediaProgress, update: .modified) } } // For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}" diff --git a/ios/App/Shared/util/Extensions.swift b/ios/App/Shared/util/Extensions.swift index 36d14b6b..c76af20c 100644 --- a/ios/App/Shared/util/Extensions.swift +++ b/ios/App/Shared/util/Extensions.swift @@ -6,8 +6,9 @@ // import Foundation -import SwiftUI import RealmSwift +import Capacitor +import CoreMedia extension String: Error {} @@ -31,6 +32,34 @@ extension Collection where Iterator.Element: Encodable { } } +extension KeyedDecodingContainer { + func doubleOrStringDecoder(key: KeyedDecodingContainer.Key) throws -> Double { + do { + return try decode(Double.self, forKey: key) + } catch { + let stringValue = try decode(String.self, forKey: key) + return Double(stringValue) ?? 0.0 + } + } + + func intOrStringDecoder(key: KeyedDecodingContainer.Key) throws -> Int { + do { + return try decode(Int.self, forKey: key) + } catch { + let stringValue = try decode(String.self, forKey: key) + return Int(stringValue) ?? 0 + } + } +} + +extension CAPPluginCall { + func getJson(_ key: String, type: T.Type) -> T? { + guard let value = getObject(key) else { return nil } + guard let json = try? JSONSerialization.data(withJSONObject: value) else { return nil } + return try? JSONDecoder().decode(type, from: json) + } +} + extension DispatchQueue { static func runOnMainQueue(callback: @escaping (() -> Void)) { if Thread.isMainThread { diff --git a/ios/App/Shared/util/NowPlayingInfo.swift b/ios/App/Shared/util/NowPlayingInfo.swift index 71e0528a..001ddcf1 100644 --- a/ios/App/Shared/util/NowPlayingInfo.swift +++ b/ios/App/Shared/util/NowPlayingInfo.swift @@ -15,6 +15,10 @@ struct NowPlayingMetadata { var title: String var author: String? var series: String? + var coverUrl: URL? { + guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(itemId)/cover?token=\(Store.serverConfig!.token)") else { return nil } + return url + } } class NowPlayingInfo { @@ -30,18 +34,27 @@ class NowPlayingInfo { public func setSessionMetadata(metadata: NowPlayingMetadata) { setMetadata(artwork: nil, metadata: metadata) - guard let url = URL(string: "\(Store.serverConfig!.address)/api/items/\(metadata.itemId)/cover?token=\(Store.serverConfig!.token)") else { - return - } - ApiClient.getData(from: url) { [self] image in - guard let downloadedImage = image else { - return + let isLocalItem = metadata.itemId.starts(with: "local_") + if isLocalItem { + guard let artworkUrl = metadata.artworkUrl else { return } + let coverImage = UIImage(contentsOfFile: artworkUrl) + guard let coverImage = coverImage else { return } + let artwork = MPMediaItemArtwork(boundsSize: coverImage.size) { _ -> UIImage in + return coverImage } - let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in - return downloadedImage - }) - self.setMetadata(artwork: artwork, metadata: metadata) + } else { + guard let url = metadata.coverUrl else { return } + ApiClient.getData(from: url) { [self] image in + guard let downloadedImage = image else { + return + } + let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in + return downloadedImage + }) + + self.setMetadata(artwork: artwork, metadata: metadata) + } } } public func update(duration: Double, currentTime: Double, rate: Float) { @@ -52,6 +65,7 @@ class NowPlayingInfo { MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } + public func reset() { nowPlayingInfo = [:] MPNowPlayingInfoCenter.default().nowPlayingInfo = nil @@ -76,6 +90,7 @@ class NowPlayingInfo { nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown" nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series } + private func shouldFetchCover(id: String) -> Bool { nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != id || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil } diff --git a/ios/App/Shared/util/PlayerEvents.swift b/ios/App/Shared/util/PlayerEvents.swift index 86bf801d..4b225a9e 100644 --- a/ios/App/Shared/util/PlayerEvents.swift +++ b/ios/App/Shared/util/PlayerEvents.swift @@ -13,4 +13,5 @@ enum PlayerEvents: String { case sleepSet = "com.audiobookshelf.app.player.sleep.set" case sleepEnded = "com.audiobookshelf.app.player.sleep.ended" case failed = "com.audiobookshelf.app.player.failed" + case localProgress = "com.audiobookshelf.app.player.localProgress" } diff --git a/pages/localMedia/item/_id.vue b/pages/localMedia/item/_id.vue index 0d825982..4b342c13 100644 --- a/pages/localMedia/item/_id.vue +++ b/pages/localMedia/item/_id.vue @@ -11,7 +11,7 @@ more_vert
-

Folder: {{ folderName }}

+

Folder: {{ folderName }}

{{ libraryItemId ? 'Linked to item on server ' + liServerAddress : 'Not linked to server item' }}

@@ -22,11 +22,11 @@

Audio Tracks ({{ audioTracks.length }})

- +