mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 09:19:46 +00:00
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
This commit is contained in:
@@ -40,12 +40,17 @@ extension DataManager {
|
|||||||
/// Deletes a local playlist.
|
/// Deletes a local playlist.
|
||||||
func deletePlaylist(_ playlist: LocalPlaylist) {
|
func deletePlaylist(_ playlist: LocalPlaylist) {
|
||||||
let playlistID = playlist.id
|
let playlistID = playlist.id
|
||||||
|
let itemIDs = playlist.sortedItems.map { $0.id }
|
||||||
|
|
||||||
modelContext.delete(playlist)
|
modelContext.delete(playlist)
|
||||||
save()
|
save()
|
||||||
|
|
||||||
// Queue for CloudKit deletion
|
// Queue playlist and all its items for CloudKit deletion
|
||||||
cloudKitSync?.queuePlaylistDelete(playlistID: playlistID)
|
cloudKitSync?.queuePlaylistDelete(playlistID: playlistID)
|
||||||
|
for itemID in itemIDs {
|
||||||
|
cloudKitSync?.queuePlaylistItemDelete(itemID: itemID)
|
||||||
|
}
|
||||||
|
|
||||||
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1412,6 +1412,13 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
|||||||
var successCount = 0
|
var successCount = 0
|
||||||
|
|
||||||
for var deferredItem in deferredPlaylistItems {
|
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
|
// Increment retry count
|
||||||
deferredItem.retryCount += 1
|
deferredItem.retryCount += 1
|
||||||
|
|
||||||
@@ -1501,14 +1508,23 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
|||||||
let saveNames = UserDefaults.standard.stringArray(forKey: pendingSaveRecordNamesKey) ?? []
|
let saveNames = UserDefaults.standard.stringArray(forKey: pendingSaveRecordNamesKey) ?? []
|
||||||
let deleteNames = UserDefaults.standard.stringArray(forKey: pendingDeleteRecordNamesKey) ?? []
|
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
|
// Also check for deferred playlist items
|
||||||
loadDeferredItems()
|
loadDeferredItems()
|
||||||
let hasDeferredItems = !deferredPlaylistItems.isEmpty
|
let hasDeferredItems = !deferredPlaylistItems.isEmpty
|
||||||
|
|
||||||
if !saveNames.isEmpty || !deleteNames.isEmpty || hasDeferredItems {
|
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")
|
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 {
|
Task {
|
||||||
await sync()
|
await sync()
|
||||||
}
|
}
|
||||||
@@ -1993,6 +2009,16 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
// Persist remaining deferred items for next sync
|
// Persist remaining deferred items for next sync
|
||||||
persistDeferredItems()
|
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
|
// Apply deletions
|
||||||
for deletion in changes.deletions {
|
for deletion in changes.deletions {
|
||||||
await applyRemoteDeletion(deletion.recordID, to: dataManager)
|
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.
|
/// Returns the result indicating success, deferral (for playlist items without parent), or failure.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func applyRemoteRecord(_ record: CKRecord, to dataManager: DataManager) async -> ApplyRecordResult {
|
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 {
|
do {
|
||||||
switch record.recordType {
|
switch record.recordType {
|
||||||
case RecordType.subscription:
|
case RecordType.subscription:
|
||||||
|
|||||||
Reference in New Issue
Block a user