// // PlayerProgressSync.swift // App // // Created by Ron Heft on 8/19/22. // import Foundation import UIKit import RealmSwift class PlayerProgress { public static let shared = PlayerProgress() private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0 private let logger = AppLogger(category: "PlayerProgress") private init() {} // MARK: - SYNC HOOKS public func syncFromPlayer(currentTime: Double, includesPlayProgress: Bool, isStopping: Bool) async { let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromPlayer") do { let session = try updateLocalSessionFromPlayer(currentTime: currentTime, includesPlayProgress: includesPlayProgress) try updateLocalMediaProgressFromLocalSession() if let session = session { try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping) } } catch { logger.error("Failed to syncFromPlayer") logger.error(error) } await UIApplication.shared.endBackgroundTask(backgroundToken) } public func syncToServer() async { let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncToServer") do { try await updateAllServerSessionFromLocalSession() } catch { logger.error("Failed to syncToServer") logger.error(error) } await UIApplication.shared.endBackgroundTask(backgroundToken) } public func syncFromServer() async { let backgroundToken = await UIApplication.shared.beginBackgroundTask(withName: "ABS:syncFromServer") do { try await updateLocalSessionFromServerMediaProgress() } catch { logger.error("Failed to syncFromServer") logger.error(error) } await UIApplication.shared.endBackgroundTask(backgroundToken) } // MARK: - SYNC LOGIC private func updateLocalSessionFromPlayer(currentTime: Double, includesPlayProgress: Bool) throws -> PlaybackSession? { guard let session = PlayerHandler.getPlaybackSession() else { return nil } guard !currentTime.isNaN else { return nil } // Prevent bad data on player stop try session.update { session.realm?.refresh() let nowInSeconds = Date().timeIntervalSince1970 let nowInMilliseconds = nowInSeconds * 1000 let lastUpdateInMilliseconds = session.updatedAt ?? nowInMilliseconds let lastUpdateInSeconds = lastUpdateInMilliseconds / 1000 let secondsSinceLastUpdate = nowInSeconds - lastUpdateInSeconds session.currentTime = currentTime session.updatedAt = nowInMilliseconds if includesPlayProgress { session.timeListening += secondsSinceLastUpdate } } return session.freeze() } private func updateLocalMediaProgressFromLocalSession() throws { guard let session = PlayerHandler.getPlaybackSession() else { return } guard session.isLocal else { return } let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: session.localMediaProgressId, localLibraryItemId: session.localLibraryItem?.id, localEpisodeId: session.episodeId) guard let localMediaProgress = localMediaProgress else { // Local media progress should have been created // If we're here, it means a library id is invalid return } try localMediaProgress.updateFromPlaybackSession(session) logger.log("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) } private func updateAllServerSessionFromLocalSession() async throws { try await withThrowingTaskGroup(of: Void.self) { [self] group in for session in try Realm(queue: nil).objects(PlaybackSession.self).where({ $0.serverConnectionConfigId == Store.serverConfig?.id }) { let session = session.freeze() group.addTask { try await self.updateServerSessionFromLocalSession(session) } } try await group.waitForAll() } } private func updateServerSessionFromLocalSession(_ session: PlaybackSession, rateLimitSync: Bool = false) async throws { if ProcessInfo.processInfo.isLowPowerModeEnabled == true { PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 60.0 } else { PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0 } var safeToSync = true guard var session = session.thaw() else { return } // We need to update and check the server time in a transaction for thread-safety try session.update { session.realm?.refresh() let nowInMilliseconds = Date().timeIntervalSince1970 * 1000 let lastUpdateInMilliseconds = session.serverUpdatedAt // If required, rate limit requests based on session last update if rateLimitSync { let timeSinceLastSync = nowInMilliseconds - lastUpdateInMilliseconds let timeBetweenSessionSync = PlayerProgress.TIME_BETWEEN_SESSION_SYNC_IN_SECONDS * 1000 safeToSync = timeSinceLastSync > timeBetweenSessionSync if !safeToSync { return // This only exits the update block } } session.serverUpdatedAt = nowInMilliseconds } session = session.freeze() guard safeToSync else { return } logger.log("Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))") var success = false if session.isLocal { success = await ApiClient.reportLocalPlaybackProgress(session) } else { let playbackReport = PlaybackReport(currentTime: session.currentTime, duration: session.duration, timeListened: session.timeListening) success = await ApiClient.reportPlaybackProgress(report: playbackReport, sessionId: session.id) // Reset time listening becuase server expects that time to be since last sync, not for the whole session if success { if let session = session.thaw() { try session.update { session.timeListening = 0 } } } } // Remove old sessions after they synced with the server if success && !session.isActiveSession { if let session = session.thaw() { try session.delete() } } } private func updateLocalSessionFromServerMediaProgress() async throws { logger.log("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server") guard let session = try Realm(queue: nil).objects(PlaybackSession.self).last(where: { $0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id })?.freeze() else { logger.log("updateLocalSessionFromServerMediaProgress: Failed to get session") return } // Fetch the current progress let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId) guard let progress = progress else { logger.log("updateLocalSessionFromServerMediaProgress: No progress object") return } // Determine which session is newer let serverLastUpdate = progress.lastUpdate guard let localLastUpdate = session.updatedAt else { logger.log("updateLocalSessionFromServerMediaProgress: No local session updatedAt") return } let serverCurrentTime = progress.currentTime let localCurrentTime = session.currentTime let serverIsNewerThanLocal = serverLastUpdate > localLastUpdate let currentTimeIsDifferent = serverCurrentTime != localCurrentTime // Update the session, if needed if serverIsNewerThanLocal && currentTimeIsDifferent { logger.log("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)") guard let session = session.thaw() else { return } try session.update { session.currentTime = serverCurrentTime session.updatedAt = serverLastUpdate } logger.log("updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)") PlayerHandler.seek(amount: session.currentTime) } else { logger.log("updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress") } } }