From c24dd3cc5250df1d7afde3c22e9ba8a8fa06fe6c Mon Sep 17 00:00:00 2001 From: Jouni Kaplas Date: Mon, 19 Jan 2026 15:20:32 +0200 Subject: [PATCH] Fix syncronization issues when loading multiple libraries --- Multiplatform/Navigation/TabRouter.swift | 20 +- .../Navigation/TabRouterViewModel.swift | 180 ++++++++++++------ .../Persistence/ProgressSubsystem.swift | 9 +- 3 files changed, 142 insertions(+), 67 deletions(-) diff --git a/Multiplatform/Navigation/TabRouter.swift b/Multiplatform/Navigation/TabRouter.swift index ee8abbd3..716509a0 100644 --- a/Multiplatform/Navigation/TabRouter.swift +++ b/Multiplatform/Navigation/TabRouter.swift @@ -36,12 +36,22 @@ struct TabRouter: View { guard let selectedLibraryID = viewModel.selectedLibraryID else { return false } - + guard viewModel.currentConnectionStatus[selectedLibraryID.connectionID] == true else { return false } - - return satellite.nowPlayingItemID != nil || lastPlayedItemID != nil + + // Active playback always visible + if satellite.nowPlayingItemID != nil { + return true + } + + // Only show resume pill if initial sync complete for that connection + if let lastPlayedItemID, viewModel.initialSyncCompleted[lastPlayedItemID.connectionID] == true { + return true + } + + return false } var connections: [FriendlyConnection] { @@ -135,7 +145,7 @@ struct TabRouter: View { } else { loadingView(startOfflineTimeout: true) .task { - viewModel.synchronize(connectionID: libraryID.connectionID) + await viewModel.synchronize(connectionID: libraryID.connectionID) } } } else { @@ -146,7 +156,7 @@ struct TabRouter: View { var body: some View { TabView(selection: $viewModel.tabValue) { - if viewModel.connectionLibraries.isEmpty { + if viewModel.connectionLibraries.isEmpty || viewModel.isLoadingLibraries { loadingTab { await viewModel.loadLibraries() } diff --git a/Multiplatform/Navigation/TabRouterViewModel.swift b/Multiplatform/Navigation/TabRouterViewModel.swift index cc485af7..a3f1ec16 100644 --- a/Multiplatform/Navigation/TabRouterViewModel.swift +++ b/Multiplatform/Navigation/TabRouterViewModel.swift @@ -34,9 +34,12 @@ final class TabRouterViewModel: Sendable { private(set) var pinnedTabValues: [TabValue] // MARK: Synchronise - + private(set) var currentConnectionStatus = [ItemIdentifier.ConnectionID: Bool]() private(set) var activeUpdateTasks = [ItemIdentifier.ConnectionID: Task]() + private(set) var initialSyncCompleted = [ItemIdentifier.ConnectionID: Bool]() + + private(set) var isLoadingLibraries = false // MARK: Helper @@ -67,48 +70,56 @@ final class TabRouterViewModel: Sendable { nonisolated func loadLibraries() async { var shouldContinue = true + await MainActor.run { + isLoadingLibraries = true + } + logger.info("Loading online UI") - - let allConnectionsUnavailable = await withTaskGroup { + + let allConnectionsAvailable = await withTaskGroup { for connectionID in await PersistenceManager.shared.authorization.connectionIDs { logger.info("Loading connection: \(connectionID)") - + $0.addTask { do { let libraries = try await ABSClient[connectionID].libraries() var results = [Library: ([TabValue], [TabValue])]() - + self.logger.info("Got libraries for connection: \(connectionID)") - + for library in libraries { let (tabBar, sideBar) = ( await PersistenceManager.shared.customization.configuredTabs(for: library.id, scope: .tabBar), await PersistenceManager.shared.customization.configuredTabs(for: library.id, scope: .sidebar), ) - + results[library] = (tabBar, sideBar) } - + await self.synchronize(connectionID: connectionID) self.logger.info("Syncrhonized connection: \(connectionID)") - + await self.didLoad(connectionID: connectionID, libraries: results) return true } catch { self.logger.info("Failed to load libraries for connection: \(connectionID)") - + await self.synchronizeFailed(connectionID: connectionID) return false } } } - + return await $0.reduce(true) { $0 && $1 } } - - if allConnectionsUnavailable { + + if !allConnectionsAvailable { await OfflineMode.shared.setEnabled(true) } + + await MainActor.run { + isLoadingLibraries = false + } } } @@ -205,21 +216,47 @@ private extension TabRouterViewModel { selectFirstCompactTab(for: .convertItemIdentifierToLibraryIdentifier(navigateToWhenReady), allowPinned: true) return true } - - guard let lastTabValue = Defaults[.lastTabValue] else { - return false + + // Prioritize lastPlayedItemID (show the library where the pill item belongs) + if let lastPlayedItemID = Defaults[.lastPlayedItemID] { + let targetLibraryID = LibraryIdentifier.convertItemIdentifierToLibraryIdentifier(lastPlayedItemID) + + if libraries.contains(where: { $0.id == targetLibraryID }) { + logger.info("Restoring library from lastPlayedItemID: \(targetLibraryID.connectionID)") + + // Check if lastTabValue exists and matches the same library - use the specific tab + if let lastTabValue = Defaults[.lastTabValue], + lastTabValue.libraryID == targetLibraryID { + logger.info("Restoring specific tab within same library from lastTabValue") + tabValue = lastTabValue + } else { + // Use first tab of the library where the played item belongs + logger.info("Selecting first tab of library where played item belongs") + selectFirstCompactTab(for: targetLibraryID, allowPinned: true) + } + return true + } else { + logger.info("Library for lastPlayedItemID not available: \(targetLibraryID.connectionID)") + } } - - if let libraryID = lastTabValue.libraryID { - guard libraries.contains(where: { $0.id == libraryID }) else { - return false + + // Fall back to lastTabValue if no lastPlayedItemID + if let lastTabValue = Defaults[.lastTabValue] { + if let libraryID = lastTabValue.libraryID { + guard libraries.contains(where: { $0.id == libraryID }) else { + logger.info("Library for lastTabValue not available: \(libraryID.id)") + return false + } + } else { + selectedLibraryID = libraries.first?.id } - } else { - selectedLibraryID = libraries.first?.id + + logger.info("Restoring from lastTabValue as fallback") + tabValue = lastTabValue + return true } - - tabValue = lastTabValue - return true + + return false } func didLoad(connectionID: ItemIdentifier.ConnectionID, libraries: [Library: ([TabValue], [TabValue])]) { @@ -240,43 +277,68 @@ private extension TabRouterViewModel { // MARK: Sync extension TabRouterViewModel { - func synchronize(connectionID: ItemIdentifier.ConnectionID) { - guard activeUpdateTasks[connectionID] == nil else { - logger.warning("Tried to start sync for \(connectionID) while it is already running") - return - } - - activeUpdateTasks[connectionID] = .init { [weak self] in - let success: Bool - let task = UIApplication.shared.beginBackgroundTask(withName: "synchronizeUserData") - - do { - let (sessions, bookmarks) = try await ABSClient[connectionID].authorize() - - try await withThrowingTaskGroup(of: Void.self) { - $0.addTask { try await PersistenceManager.shared.progress.compareDatabase(against: sessions, connectionID: connectionID) } - $0.addTask { try await PersistenceManager.shared.bookmark.sync(bookmarks: bookmarks, connectionID: connectionID) } - - try await $0.waitForAll() - } - - success = true - } catch { - self?.logger.error("Failed to synchronize \(connectionID, privacy: .public): \(error, privacy: .public)") - success = false - } - - UIApplication.shared.endBackgroundTask(task) - - guard !Task.isCancelled, let self else { - return + func synchronize(connectionID: ItemIdentifier.ConnectionID) async { + // Atomically check for existing task or create new one on MainActor + let task = await MainActor.run { () -> Task in + // If there's already a task running, return it + if let existingTask = activeUpdateTasks[connectionID] { + logger.info("Sync already running for \(connectionID), awaiting existing task") + return existingTask } - - await MainActor.withAnimation { - currentConnectionStatus[connectionID] = success - activeUpdateTasks[connectionID] = nil + + let isInitialSync = initialSyncCompleted[connectionID] != true + + // Create the task + let newTask: Task = .init { [weak self] in + let success: Bool + let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "synchronizeUserData") + + do { + let (sessions, bookmarks) = try await ABSClient[connectionID].authorize() + + try await withThrowingTaskGroup(of: Void.self) { + $0.addTask { + try await PersistenceManager.shared.progress.compareDatabase( + against: sessions, + connectionID: connectionID, + isInitialSync: isInitialSync + ) + } + $0.addTask { try await PersistenceManager.shared.bookmark.sync(bookmarks: bookmarks, connectionID: connectionID) } + + try await $0.waitForAll() + } + + success = true + } catch { + self?.logger.error("Failed to synchronize \(connectionID, privacy: .public): \(error, privacy: .public)") + success = false + } + + UIApplication.shared.endBackgroundTask(backgroundTask) + + guard !Task.isCancelled, let self else { + return + } + + await MainActor.withAnimation { + currentConnectionStatus[connectionID] = success + activeUpdateTasks[connectionID] = nil + + // Mark initial sync as complete + if success && isInitialSync { + initialSyncCompleted[connectionID] = true + } + } } + + // Store the task atomically + activeUpdateTasks[connectionID] = newTask + return newTask } + + // Await the task (either existing or newly created) + await task.value } func synchronizeFailed(connectionID: ItemIdentifier.ConnectionID) { currentConnectionStatus[connectionID] = false diff --git a/ShelfPlayerKit/Persistence/ProgressSubsystem.swift b/ShelfPlayerKit/Persistence/ProgressSubsystem.swift index c7cf7c54..7b6038b0 100644 --- a/ShelfPlayerKit/Persistence/ProgressSubsystem.swift +++ b/ShelfPlayerKit/Persistence/ProgressSubsystem.swift @@ -155,7 +155,7 @@ public extension PersistenceManager.ProgressSubsystem { } } - func compareDatabase(against payload: [ProgressPayload], connectionID: ItemIdentifier.ConnectionID) async throws { + func compareDatabase(against payload: [ProgressPayload], connectionID: ItemIdentifier.ConnectionID, isInitialSync: Bool = true) async throws { var remoteDuplicates = [String]() let keyedPayload = payload.map { @@ -207,11 +207,14 @@ public extension PersistenceManager.ProgressSubsystem { for key in localOnly { let entity = local[key]! - + if entity.status == .desynchronized { pendingServerUpdate[key] = entity - } else { + } else if !isInitialSync { + // Only delete on subsequent syncs, not initial sync pendingLocalDeletion.append(entity.id) + } else { + logger.info("Skipping deletion of local-only progress during initial sync: \(entity.id)") } }