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 6520c9b2..245c0a63 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 { @@ -125,6 +126,111 @@ public class AbsDatabase: CAPPlugin { } } + @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 localLibraryItemId = localLibraryItemId else { + call.reject("localLibraryItemId not specified") + return + } + guard let serverMediaProgress = serverMediaProgress else { + call.reject("serverMediaProgress not specified") + return + } + + let 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 = 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) + } + } + + private 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 + } + + } + @objc func updateDeviceSettings(_ call: CAPPluginCall) { let disableAutoRewind = call.getBool("disableAutoRewind") ?? false let enableAltView = call.getBool("enableAltView") ?? false diff --git a/ios/App/Shared/models/LocalLibraryExtensions.swift b/ios/App/Shared/models/LocalLibraryExtensions.swift index 4ef0159a..2fa3532f 100644 --- a/ios/App/Shared/models/LocalLibraryExtensions.swift +++ b/ios/App/Shared/models/LocalLibraryExtensions.swift @@ -130,24 +130,36 @@ extension LocalFile { } extension LocalMediaProgress { - init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?, progress: MediaProgress) { + init(localLibraryItem: LocalLibraryItem, episode: PodcastEpisode?) { 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 = 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 = false + self.isFinished = progress.isFinished self.lastUpdate = progress.lastUpdate self.startedAt = progress.startedAt self.finishedAt = progress.finishedAt @@ -157,6 +169,10 @@ extension LocalMediaProgress { 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) diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index c9ed9e97..58507ed6 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -37,6 +37,7 @@ class ApiClient { } } } + public static func postResource(endpoint: String, parameters: [String: String], callback: ((_ success: Bool) -> Void)?) { if (Store.serverConfig == nil) { NSLog("Server config not set") @@ -50,7 +51,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 +61,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") @@ -119,6 +144,41 @@ class ApiClient { try? postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report.asDictionary().mapValues({ value in "\(value)" }), callback: nil) } + 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") + try? postResource(endpoint: "/api/me/sync-local-progress", parameters: payload.asDictionary(), 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 +190,18 @@ class ApiClient { } } } + +struct LocalMediaProgressSyncPayload: Codable { + var localMediaProgress: [LocalMediaProgress] +} + +struct MediaProgressSyncResponsePayload: Codable { + var numServerProgressUpdates: Int? + var localProgressUpdates: [LocalMediaProgress]? +} + +struct LocalMediaProgressSyncResultsPayload: Codable { + var numLocalMediaProgressForServer: Int? + var numServerProgressUpdates: Int? + var numLocalProgressUpdates: Int? +} diff --git a/ios/App/Shared/util/Extensions.swift b/ios/App/Shared/util/Extensions.swift index 36d14b6b..ba7c4531 100644 --- a/ios/App/Shared/util/Extensions.swift +++ b/ios/App/Shared/util/Extensions.swift @@ -6,8 +6,8 @@ // import Foundation -import SwiftUI import RealmSwift +import Capacitor extension String: Error {} @@ -31,6 +31,14 @@ extension Collection where Iterator.Element: Encodable { } } +extension CAPPluginCall { + func getJson(_ key: String, type: T.Type) -> T? { + guard let value = getString(key) else { return nil } + guard let valueData = value.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(type, from: valueData) + } +} + extension DispatchQueue { static func runOnMainQueue(callback: @escaping (() -> Void)) { if Thread.isMainThread {