From 6aa0f2253bde5b91af740b9780d95f1a0ec1d2a5 Mon Sep 17 00:00:00 2001 From: ronaldheft Date: Tue, 16 Aug 2022 20:45:29 -0400 Subject: [PATCH] Sync offline playback progress --- ios/App/App/AppDelegate.swift | 2 +- ios/App/Shared/models/PlaybackSession.swift | 26 +++++++- ios/App/Shared/player/PlayerHandler.swift | 67 +++++++++++++++------ ios/App/Shared/util/ApiClient.swift | 4 +- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 99d44fe4..6e93aab0 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -11,7 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Override point for customization after application launch. let configuration = Realm.Configuration( - schemaVersion: 2, + schemaVersion: 3, migrationBlock: { migration, oldSchemaVersion in if (oldSchemaVersion < 1) { NSLog("Realm schema version was \(oldSchemaVersion)") diff --git a/ios/App/Shared/models/PlaybackSession.swift b/ios/App/Shared/models/PlaybackSession.swift index 9fadc1d3..1c54cf53 100644 --- a/ios/App/Shared/models/PlaybackSession.swift +++ b/ios/App/Shared/models/PlaybackSession.swift @@ -8,7 +8,7 @@ import Foundation import RealmSwift -class PlaybackSession: Object, Codable { +class PlaybackSession: Object, Codable, Deletable { @Persisted(primaryKey: true) var id: String = "" var userId: String? @Persisted var libraryItemId: String? @@ -20,7 +20,7 @@ class PlaybackSession: Object, Codable { @Persisted var displayAuthor: String? @Persisted var coverPath: String? @Persisted var duration: Double = 0 - @Persisted var playMethod: Int = 1 + @Persisted var playMethod: Int = PlayMethod.directplay.rawValue @Persisted var startedAt: Double? @Persisted var updatedAt: Double? @Persisted var timeListening: Double = 0 @@ -30,8 +30,24 @@ class PlaybackSession: Object, Codable { @Persisted var localLibraryItem: LocalLibraryItem? @Persisted var serverConnectionConfigId: String? @Persisted var serverAddress: String? + @Persisted var isActiveSession = true var isLocal: Bool { self.localLibraryItem != nil } + var mediaPlayer: String { "AVPlayer" } + var deviceInfo: [String: String?]? { + var systemInfo = utsname() + uname(&systemInfo) + let modelCode = withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + ptr in String.init(validatingUTF8: ptr) + } + } + return [ + "manufacturer": "Apple", + "model": modelCode, + "clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + ] + } var localMediaProgressId: String? { if let localLibraryItem = localLibraryItem, let episodeId = episodeId { @@ -72,10 +88,11 @@ class PlaybackSession: Object, Codable { self.localLibraryItem = localLibraryItem self.serverConnectionConfigId = serverConnectionConfigId self.serverAddress = serverAddress + self.isActiveSession = true } private enum CodingKeys : String, CodingKey { - case id, userId, libraryItemId, episodeId, mediaType, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId + case id, userId, libraryItemId, episodeId, mediaType, chapters, displayTitle, displayAuthor, coverPath, duration, playMethod, mediaPlayer, deviceInfo, startedAt, updatedAt, timeListening, audioTracks, currentTime, libraryItem, localLibraryItem, serverConnectionConfigId, serverAddress, isLocal, localMediaProgressId } override init() {} @@ -107,6 +124,7 @@ class PlaybackSession: Object, Codable { localLibraryItem = try values.decodeIfPresent(LocalLibraryItem.self, forKey: .localLibraryItem) serverConnectionConfigId = try values.decodeIfPresent(String.self, forKey: .serverConnectionConfigId) serverAddress = try values.decodeIfPresent(String.self, forKey: .serverAddress) + isActiveSession = true } func encode(to encoder: Encoder) throws { @@ -122,6 +140,8 @@ class PlaybackSession: Object, Codable { try container.encode(coverPath, forKey: .coverPath) try container.encode(duration, forKey: .duration) try container.encode(playMethod, forKey: .playMethod) + try container.encode(mediaPlayer, forKey: .mediaPlayer) + try container.encode(deviceInfo, forKey: .deviceInfo) try container.encode(startedAt, forKey: .startedAt) try container.encode(updatedAt, forKey: .updatedAt) try container.encode(timeListening, forKey: .timeListening) diff --git a/ios/App/Shared/player/PlayerHandler.swift b/ios/App/Shared/player/PlayerHandler.swift index 9fc3afa9..cf9d8ef1 100644 --- a/ios/App/Shared/player/PlayerHandler.swift +++ b/ios/App/Shared/player/PlayerHandler.swift @@ -34,7 +34,6 @@ class PlayerHandler { } } private static var listeningTimePassedSinceLastSync: Double = 0.0 - private static var lastSyncReport: PlaybackReport? public static var paused: Bool { get { @@ -69,15 +68,35 @@ class PlayerHandler { timer = nil } + private static func cleanupOldSessions(currentSessionId: String?) { + if let currentSessionId = currentSessionId { + let realm = try! Realm() + let oldSessions = realm.objects(PlaybackSession.self) + .where({ $0.isActiveSession == true && $0.id != currentSessionId }) + try! realm.write { + for s in oldSessions { + s.isActiveSession = false + } + } + } + } + public static func startPlayback(sessionId: String, playWhenReady: Bool, playbackRate: Float) { guard let session = Database.shared.getPlaybackSession(id: sessionId) else { return } + + // Clean up the existing player if player != nil { player?.destroy() player = nil } + // Cleanup old sessions + cleanupOldSessions(currentSessionId: sessionId) + + // Set now playing info NowPlayingInfo.shared.setSessionMetadata(metadata: NowPlayingMetadata(id: session.id, itemId: session.libraryItemId!, artworkUrl: session.coverPath, title: session.displayTitle ?? "Unknown title", author: session.displayAuthor, series: nil)) + // Create the audio player player = AudioPlayer(sessionId: sessionId, playWhenReady: playWhenReady, playbackRate: playbackRate) startTickTimer() @@ -93,6 +112,8 @@ class PlayerHandler { player?.destroy() player = nil + cleanupOldSessions(currentSessionId: nil) + NowPlayingInfo.shared.reset() } @@ -180,10 +201,8 @@ class PlayerHandler { guard let player = player else { return } guard let session = getPlaybackSession() else { return } - // Prevent a sync at the current time + // Get current time let playerCurrentTime = player.getCurrentTime() - let hasSyncAtCurrentTime = lastSyncReport?.currentTime.isEqual(to: playerCurrentTime) ?? false - if hasSyncAtCurrentTime { return } // Prevent multiple sync requests let timeSinceLastSync = Date().timeIntervalSince1970 - lastSyncTime @@ -196,29 +215,18 @@ class PlayerHandler { guard !playerCurrentTime.isNaN else { return } lastSyncTime = Date().timeIntervalSince1970 // seconds - let report = PlaybackReport(currentTime: playerCurrentTime, duration: player.getDuration(), timeListened: listeningTimePassedSinceLastSync) session.update { session.currentTime = playerCurrentTime + session.timeListening += listeningTimePassedSinceLastSync } listeningTimePassedSinceLastSync = 0 - lastSyncReport = report - 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)") - } - } - } + // Persist items in the database and sync to the server + if session.isLocal { + _ = syncLocalProgress() } + syncPlaybackSessionsToServer() } private static func syncLocalProgress() -> LocalMediaProgress? { @@ -241,4 +249,23 @@ class PlayerHandler { return localMediaProgress } + + private static func syncPlaybackSessionsToServer() { + guard Connectivity.isConnectedToInternet else { return } + DispatchQueue.global(qos: .utility).async { + let realm = try! Realm() + for session in realm.objects(PlaybackSession.self) { + NSLog("Sending sessionId(\(session.id)) to server") + let sessionRef = ThreadSafeReference(to: session) + ApiClient.reportLocalPlaybackProgress(session.freeze()) { success in + // Remove old sessions after they synced with the server + let session = try! Realm().resolve(sessionRef) + if success && !(session?.isActiveSession ?? false) { + NSLog("Deleting sessionId(\(session!.id)) as is no longer active") + session?.delete() + } + } + } + } + } } diff --git a/ios/App/Shared/util/ApiClient.swift b/ios/App/Shared/util/ApiClient.swift index cbf3068b..913d2be4 100644 --- a/ios/App/Shared/util/ApiClient.swift +++ b/ios/App/Shared/util/ApiClient.swift @@ -166,8 +166,8 @@ 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) { - let progress = localMediaProgress.freeze() + public static func reportLocalPlaybackProgress(_ session: PlaybackSession, callback: @escaping (_ success: Bool) -> Void) { + let progress = session.freeze() postResource(endpoint: "api/session/local", parameters: progress, callback: callback) }