From 3aadc9be7076e5cd9a1b2371201c1aebf6a7cfc5 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 13 Feb 2026 18:05:30 +0100 Subject: [PATCH] Fix deleted playlists resurrecting from iCloud after app restart Pending deletes were lost across app restarts because recoverPersistedPendingChanges() never reconstructed CKRecord.ID objects from persisted record names. Additionally, incoming iCloud records for deleted playlists were blindly applied, and orphaned playlist items in CloudKit would recreate placeholder playlists. - Rebuild pendingDeletes array from UserDefaults on recovery - Guard applyRemoteRecord against records pending local deletion - Skip deferred items whose parent playlist is pending deletion - Queue all playlist item deletions when deleting a playlist - Clean up placeholder playlists for pending-delete playlists --- Yattee/Data/DataManager+Playlists.swift | 11 +++-- .../CloudKit/CloudKitSyncEngine.swift | 46 ++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/Yattee/Data/DataManager+Playlists.swift b/Yattee/Data/DataManager+Playlists.swift index 70958f31..7c8c65ba 100644 --- a/Yattee/Data/DataManager+Playlists.swift +++ b/Yattee/Data/DataManager+Playlists.swift @@ -40,12 +40,17 @@ extension DataManager { /// Deletes a local playlist. func deletePlaylist(_ playlist: LocalPlaylist) { let playlistID = playlist.id + let itemIDs = playlist.sortedItems.map { $0.id } + modelContext.delete(playlist) save() - - // Queue for CloudKit deletion + + // Queue playlist and all its items for CloudKit deletion cloudKitSync?.queuePlaylistDelete(playlistID: playlistID) - + for itemID in itemIDs { + cloudKitSync?.queuePlaylistItemDelete(itemID: itemID) + } + NotificationCenter.default.post(name: .playlistsDidChange, object: nil) } diff --git a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift index 7bac8142..d0feadfb 100644 --- a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift +++ b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift @@ -1412,6 +1412,13 @@ final class CloudKitSyncEngine: @unchecked Sendable { var successCount = 0 for var deferredItem in deferredPlaylistItems { + // Drop items whose parent playlist is pending deletion + let playlistRecordName = "playlist-\(deferredItem.playlistID)" + if pendingDeletes.contains(where: { $0.recordName == playlistRecordName }) { + LoggingService.shared.logCloudKit("Dropping deferred item (parent playlist pending delete): \(deferredItem.itemID)") + continue + } + // Increment retry count deferredItem.retryCount += 1 @@ -1501,14 +1508,23 @@ final class CloudKitSyncEngine: @unchecked Sendable { let saveNames = UserDefaults.standard.stringArray(forKey: pendingSaveRecordNamesKey) ?? [] let deleteNames = UserDefaults.standard.stringArray(forKey: pendingDeleteRecordNamesKey) ?? [] + // Reconstruct pending deletes from persisted record names + if !deleteNames.isEmpty { + let zone = CKRecordZone(zoneName: RecordType.zoneName) + for name in deleteNames { + let recordID = CKRecord.ID(recordName: name, zoneID: zone.zoneID) + if !pendingDeletes.contains(where: { $0.recordName == name }) { + pendingDeletes.append(recordID) + } + } + } + // Also check for deferred playlist items loadDeferredItems() let hasDeferredItems = !deferredPlaylistItems.isEmpty if !saveNames.isEmpty || !deleteNames.isEmpty || hasDeferredItems { LoggingService.shared.logCloudKit("Recovered \(saveNames.count) pending saves, \(deleteNames.count) pending deletes, \(deferredPlaylistItems.count) deferred items from previous session") - // Trigger immediate sync to process any recovered changes - // The actual records will be recreated from local data during sync Task { await sync() } @@ -1993,6 +2009,16 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate { // Persist remaining deferred items for next sync persistDeferredItems() + // Clean up placeholders whose parent playlist is pending deletion + let allPlaylists = dataManager.playlists() + for playlist in allPlaylists where playlist.isPlaceholder { + let playlistRecordName = "playlist-\(playlist.id.uuidString)" + if pendingDeletes.contains(where: { $0.recordName == playlistRecordName }) { + dataManager.deletePlaylist(playlist) + LoggingService.shared.logCloudKit("Cleaned up placeholder for deleted playlist: \(playlist.id)") + } + } + // Apply deletions for deletion in changes.deletions { await applyRemoteDeletion(deletion.recordID, to: dataManager) @@ -2010,6 +2036,22 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate { /// Returns the result indicating success, deferral (for playlist items without parent), or failure. @discardableResult private func applyRemoteRecord(_ record: CKRecord, to dataManager: DataManager) async -> ApplyRecordResult { + // Skip records that are pending local deletion + if pendingDeletes.contains(where: { $0.recordName == record.recordID.recordName }) { + LoggingService.shared.logCloudKit("Skipping incoming record (pending local delete): \(record.recordID.recordName)") + return .success + } + + // For playlist items, also skip if parent playlist is pending deletion + if record.recordType == RecordType.localPlaylistItem, + let playlistIDString = record["playlistID"] as? String { + let playlistRecordName = "playlist-\(playlistIDString)" + if pendingDeletes.contains(where: { $0.recordName == playlistRecordName }) { + LoggingService.shared.logCloudKit("Skipping playlist item (parent playlist pending delete): \(record.recordID.recordName)") + return .success + } + } + do { switch record.recordType { case RecordType.subscription: