mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
Make CloudKit apply path scope-aware and re-upload local-newer merges
CloudKit record names carry a source scope suffix so the same channel or video ID can exist under different sources, but the apply path matched local entities by bare ID: incoming records could merge into an entity from a different scope, and a remote deletion of one scope deleted every local entity with that ID and cascaded scoped deletions for all other scopes back to CloudKit. - Match local entities on the full mapper-derived record name when merging incoming records, materializing records for upload, and applying remote deletions. - Remote deletions now remove only the matching entity and no longer queue CloudKit deletions (no echo, no cross-scope cascade). - Insert dedupe for CloudKit-applied records is scope-aware, so a same-ID record from a different source inserts instead of being silently dropped. Remotely added bookmarks also update the fast-lookup cache immediately. - When conflict resolution during a fetch keeps newer local data, the merge result is queued for upload; previously the server (and other devices) kept the stale version unless the entity was edited again. Strict timestamp comparisons prevent re-upload ping-pong between devices.
This commit is contained in:
@@ -119,26 +119,55 @@ extension DataManager {
|
|||||||
)
|
)
|
||||||
return try? modelContext.fetch(descriptor).first
|
return try? modelContext.fetch(descriptor).first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets all bookmarks matching a video ID. The same ID can exist
|
||||||
|
/// under multiple source scopes; callers that care about a specific
|
||||||
|
/// source must pick the matching entity themselves.
|
||||||
|
func bookmarks(forVideoID videoID: String) -> [Bookmark] {
|
||||||
|
let descriptor = FetchDescriptor<Bookmark>(
|
||||||
|
predicate: #Predicate { $0.videoID == videoID }
|
||||||
|
)
|
||||||
|
return (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a single bookmark without queueing a CloudKit deletion.
|
||||||
|
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||||
|
func deleteBookmark(_ bookmark: Bookmark) {
|
||||||
|
let videoID = bookmark.videoID
|
||||||
|
modelContext.delete(bookmark)
|
||||||
|
save()
|
||||||
|
|
||||||
|
// Keep the fast-lookup cache accurate; another scope may still
|
||||||
|
// have a bookmark with this video ID
|
||||||
|
if self.bookmark(for: videoID) == nil {
|
||||||
|
cachedBookmarkedVideoIDs.remove(videoID)
|
||||||
|
}
|
||||||
|
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
||||||
|
}
|
||||||
|
|
||||||
/// Inserts a bookmark into the database.
|
/// Inserts a bookmark into the database.
|
||||||
/// Used by CloudKitSyncEngine for applying remote bookmarks.
|
/// Used by CloudKitSyncEngine for applying remote bookmarks.
|
||||||
func insertBookmark(_ bookmark: Bookmark) {
|
func insertBookmark(_ bookmark: Bookmark) {
|
||||||
// Check for duplicates
|
// Check for duplicates within the same source scope - the same
|
||||||
|
// video ID can legitimately exist under different sources
|
||||||
let videoID = bookmark.videoID
|
let videoID = bookmark.videoID
|
||||||
let descriptor = FetchDescriptor<Bookmark>(
|
let descriptor = FetchDescriptor<Bookmark>(
|
||||||
predicate: #Predicate { $0.videoID == videoID }
|
predicate: #Predicate { $0.videoID == videoID }
|
||||||
)
|
)
|
||||||
|
let scopeSuffix = bookmark.sourceScopeSuffix
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let existing = try modelContext.fetch(descriptor)
|
let existing = try modelContext.fetch(descriptor)
|
||||||
if existing.isEmpty {
|
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||||
modelContext.insert(bookmark)
|
modelContext.insert(bookmark)
|
||||||
save()
|
save()
|
||||||
|
cachedBookmarkedVideoIDs.insert(videoID)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Insert anyway if we can't check
|
// Insert anyway if we can't check
|
||||||
modelContext.insert(bookmark)
|
modelContext.insert(bookmark)
|
||||||
save()
|
save()
|
||||||
|
cachedBookmarkedVideoIDs.insert(videoID)
|
||||||
}
|
}
|
||||||
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
|
||||||
}
|
}
|
||||||
@@ -180,3 +209,17 @@ extension DataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Scope
|
||||||
|
|
||||||
|
private extension Bookmark {
|
||||||
|
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||||
|
var sourceScopeSuffix: String {
|
||||||
|
SourceScope.from(
|
||||||
|
sourceRawValue: sourceRawValue,
|
||||||
|
globalProvider: globalProvider,
|
||||||
|
instanceURLString: instanceURLString,
|
||||||
|
externalExtractor: externalExtractor
|
||||||
|
).recordNameSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,22 @@ extension DataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets all notification settings matching a channel ID. The same ID can
|
||||||
|
/// exist under multiple source scopes.
|
||||||
|
func allChannelNotificationSettings(forChannelID channelID: String) -> [ChannelNotificationSettings] {
|
||||||
|
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||||
|
predicate: #Predicate { $0.channelID == channelID }
|
||||||
|
)
|
||||||
|
return (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a single notification settings record without queueing a
|
||||||
|
/// CloudKit deletion. Used by CloudKitSyncEngine when applying remote deletions.
|
||||||
|
func deleteChannelNotificationSettings(_ settings: ChannelNotificationSettings) {
|
||||||
|
modelContext.delete(settings)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes notification settings for a channel.
|
/// Deletes notification settings for a channel.
|
||||||
/// - Parameter channelID: The channel ID to delete settings for.
|
/// - Parameter channelID: The channel ID to delete settings for.
|
||||||
func deleteNotificationSettings(for channelID: String) {
|
func deleteNotificationSettings(for channelID: String) {
|
||||||
|
|||||||
@@ -300,14 +300,34 @@ extension DataManager {
|
|||||||
let descriptor = FetchDescriptor<RecentChannel>(
|
let descriptor = FetchDescriptor<RecentChannel>(
|
||||||
predicate: #Predicate { $0.channelID == channelID }
|
predicate: #Predicate { $0.channelID == channelID }
|
||||||
)
|
)
|
||||||
|
|
||||||
return try? modelContext.fetch(descriptor).first
|
return try? modelContext.fetch(descriptor).first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets all recent channels matching a channel ID. The same ID can exist
|
||||||
|
/// under multiple source scopes.
|
||||||
|
func recentChannelEntries(forChannelID channelID: String) -> [RecentChannel] {
|
||||||
|
let descriptor = FetchDescriptor<RecentChannel>(
|
||||||
|
predicate: #Predicate { $0.channelID == channelID }
|
||||||
|
)
|
||||||
|
return (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a recent channel without queueing a CloudKit deletion.
|
||||||
|
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||||
|
func deleteRecentChannelEntry(_ channel: RecentChannel) {
|
||||||
|
modelContext.delete(channel)
|
||||||
|
save()
|
||||||
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
/// Inserts a recent channel (for CloudKit sync).
|
/// Inserts a recent channel (for CloudKit sync).
|
||||||
func insertRecentChannel(_ recentChannel: RecentChannel) {
|
func insertRecentChannel(_ recentChannel: RecentChannel) {
|
||||||
// Check for duplicates by channelID
|
// Check for duplicates within the same source scope - the same
|
||||||
if recentChannelEntry(forChannelID: recentChannel.channelID) == nil {
|
// channel ID can legitimately exist under different sources
|
||||||
|
let scopeSuffix = recentChannel.sourceScopeSuffix
|
||||||
|
let existing = recentChannelEntries(forChannelID: recentChannel.channelID)
|
||||||
|
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||||
modelContext.insert(recentChannel)
|
modelContext.insert(recentChannel)
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
@@ -467,16 +487,62 @@ extension DataManager {
|
|||||||
let descriptor = FetchDescriptor<RecentPlaylist>(
|
let descriptor = FetchDescriptor<RecentPlaylist>(
|
||||||
predicate: #Predicate { $0.playlistID == playlistID }
|
predicate: #Predicate { $0.playlistID == playlistID }
|
||||||
)
|
)
|
||||||
|
|
||||||
return try? modelContext.fetch(descriptor).first
|
return try? modelContext.fetch(descriptor).first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets all recent playlists matching a playlist ID. The same ID can
|
||||||
|
/// exist under multiple source scopes.
|
||||||
|
func recentPlaylistEntries(forPlaylistID playlistID: String) -> [RecentPlaylist] {
|
||||||
|
let descriptor = FetchDescriptor<RecentPlaylist>(
|
||||||
|
predicate: #Predicate { $0.playlistID == playlistID }
|
||||||
|
)
|
||||||
|
return (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a recent playlist without queueing a CloudKit deletion.
|
||||||
|
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||||
|
func deleteRecentPlaylistEntry(_ playlist: RecentPlaylist) {
|
||||||
|
modelContext.delete(playlist)
|
||||||
|
save()
|
||||||
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
/// Inserts a recent playlist (for CloudKit sync).
|
/// Inserts a recent playlist (for CloudKit sync).
|
||||||
func insertRecentPlaylist(_ recentPlaylist: RecentPlaylist) {
|
func insertRecentPlaylist(_ recentPlaylist: RecentPlaylist) {
|
||||||
// Check for duplicates by playlistID
|
// Check for duplicates within the same source scope - the same
|
||||||
if recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) == nil {
|
// playlist ID can legitimately exist under different sources
|
||||||
|
let scopeSuffix = recentPlaylist.sourceScopeSuffix
|
||||||
|
let existing = recentPlaylistEntries(forPlaylistID: recentPlaylist.playlistID)
|
||||||
|
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||||
modelContext.insert(recentPlaylist)
|
modelContext.insert(recentPlaylist)
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Scope
|
||||||
|
|
||||||
|
private extension RecentChannel {
|
||||||
|
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||||
|
var sourceScopeSuffix: String {
|
||||||
|
SourceScope.from(
|
||||||
|
sourceRawValue: sourceRawValue,
|
||||||
|
globalProvider: nil,
|
||||||
|
instanceURLString: instanceURLString,
|
||||||
|
externalExtractor: nil
|
||||||
|
).recordNameSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension RecentPlaylist {
|
||||||
|
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||||
|
var sourceScopeSuffix: String {
|
||||||
|
SourceScope.from(
|
||||||
|
sourceRawValue: sourceRawValue,
|
||||||
|
globalProvider: nil,
|
||||||
|
instanceURLString: instanceURLString,
|
||||||
|
externalExtractor: nil
|
||||||
|
).recordNameSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,16 @@ extension DataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets all subscriptions matching a channel ID. The same ID can exist
|
||||||
|
/// under multiple source scopes; callers that care about a specific
|
||||||
|
/// source must pick the matching entity themselves.
|
||||||
|
func subscriptions(forChannelID channelID: String) -> [Subscription] {
|
||||||
|
let descriptor = FetchDescriptor<Subscription>(
|
||||||
|
predicate: #Predicate { $0.channelID == channelID }
|
||||||
|
)
|
||||||
|
return (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets all subscriptions.
|
/// Gets all subscriptions.
|
||||||
func subscriptions() -> [Subscription] {
|
func subscriptions() -> [Subscription] {
|
||||||
let descriptor = FetchDescriptor<Subscription>(
|
let descriptor = FetchDescriptor<Subscription>(
|
||||||
@@ -219,15 +229,17 @@ extension DataManager {
|
|||||||
/// Inserts a subscription into the database.
|
/// Inserts a subscription into the database.
|
||||||
/// Used by SubscriptionService for caching server subscriptions locally.
|
/// Used by SubscriptionService for caching server subscriptions locally.
|
||||||
func insertSubscription(_ subscription: Subscription) {
|
func insertSubscription(_ subscription: Subscription) {
|
||||||
// Check for duplicates
|
// Check for duplicates within the same source scope - the same
|
||||||
|
// channel ID can legitimately exist under different sources
|
||||||
let channelID = subscription.channelID
|
let channelID = subscription.channelID
|
||||||
let descriptor = FetchDescriptor<Subscription>(
|
let descriptor = FetchDescriptor<Subscription>(
|
||||||
predicate: #Predicate { $0.channelID == channelID }
|
predicate: #Predicate { $0.channelID == channelID }
|
||||||
)
|
)
|
||||||
|
let scopeSuffix = subscription.sourceScopeSuffix
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let existing = try modelContext.fetch(descriptor)
|
let existing = try modelContext.fetch(descriptor)
|
||||||
if existing.isEmpty {
|
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||||
modelContext.insert(subscription)
|
modelContext.insert(subscription)
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
@@ -394,3 +406,17 @@ extension DataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Scope
|
||||||
|
|
||||||
|
private extension Subscription {
|
||||||
|
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||||
|
var sourceScopeSuffix: String {
|
||||||
|
SourceScope.from(
|
||||||
|
sourceRawValue: sourceRawValue,
|
||||||
|
globalProvider: providerName,
|
||||||
|
instanceURLString: instanceURLString,
|
||||||
|
externalExtractor: nil
|
||||||
|
).recordNameSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,19 +128,39 @@ extension DataManager {
|
|||||||
)
|
)
|
||||||
return try? modelContext.fetch(descriptor).first
|
return try? modelContext.fetch(descriptor).first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets all watch entries matching a video ID. The same ID can exist
|
||||||
|
/// under multiple source scopes; callers that care about a specific
|
||||||
|
/// source must pick the matching entity themselves.
|
||||||
|
func watchEntries(forVideoID videoID: String) -> [WatchEntry] {
|
||||||
|
let descriptor = FetchDescriptor<WatchEntry>(
|
||||||
|
predicate: #Predicate { $0.videoID == videoID }
|
||||||
|
)
|
||||||
|
return (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a single watch entry without queueing a CloudKit deletion.
|
||||||
|
/// Used by CloudKitSyncEngine when applying remote deletions.
|
||||||
|
func deleteWatchEntry(_ entry: WatchEntry) {
|
||||||
|
modelContext.delete(entry)
|
||||||
|
save()
|
||||||
|
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
|
||||||
|
}
|
||||||
|
|
||||||
/// Inserts a watch entry into the database.
|
/// Inserts a watch entry into the database.
|
||||||
/// Used by CloudKitSyncEngine for applying remote watch history.
|
/// Used by CloudKitSyncEngine for applying remote watch history.
|
||||||
func insertWatchEntry(_ watchEntry: WatchEntry) {
|
func insertWatchEntry(_ watchEntry: WatchEntry) {
|
||||||
// Check for duplicates
|
// Check for duplicates within the same source scope - the same
|
||||||
|
// video ID can legitimately exist under different sources
|
||||||
let videoID = watchEntry.videoID
|
let videoID = watchEntry.videoID
|
||||||
let descriptor = FetchDescriptor<WatchEntry>(
|
let descriptor = FetchDescriptor<WatchEntry>(
|
||||||
predicate: #Predicate { $0.videoID == videoID }
|
predicate: #Predicate { $0.videoID == videoID }
|
||||||
)
|
)
|
||||||
|
let scopeSuffix = watchEntry.sourceScopeSuffix
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let existing = try modelContext.fetch(descriptor)
|
let existing = try modelContext.fetch(descriptor)
|
||||||
if existing.isEmpty {
|
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
|
||||||
modelContext.insert(watchEntry)
|
modelContext.insert(watchEntry)
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
@@ -376,3 +396,17 @@ extension DataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Scope
|
||||||
|
|
||||||
|
private extension WatchEntry {
|
||||||
|
/// Record-name scope suffix used to distinguish same-ID entities across sources.
|
||||||
|
var sourceScopeSuffix: String {
|
||||||
|
SourceScope.from(
|
||||||
|
sourceRawValue: sourceRawValue,
|
||||||
|
globalProvider: globalProvider,
|
||||||
|
instanceURLString: instanceURLString,
|
||||||
|
externalExtractor: externalExtractor
|
||||||
|
).recordNameSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1694,52 +1694,90 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Scope-Aware Local Lookups
|
||||||
|
|
||||||
|
/// Bare-ID lookups can return an entity from a different source scope when
|
||||||
|
/// IDs collide across sources; these helpers match on the mapper-derived
|
||||||
|
/// record name so the right entity is merged, materialized, or deleted.
|
||||||
|
private func localSubscription(matching recordName: String, channelID: String, in dataManager: DataManager) -> Subscription? {
|
||||||
|
dataManager.subscriptions(forChannelID: channelID).first {
|
||||||
|
recordMapper.toCKRecord(subscription: $0).recordID.recordName == recordName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localWatchEntry(matching recordName: String, videoID: String, in dataManager: DataManager) -> WatchEntry? {
|
||||||
|
dataManager.watchEntries(forVideoID: videoID).first {
|
||||||
|
recordMapper.toCKRecord(watchEntry: $0).recordID.recordName == recordName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localBookmark(matching recordName: String, videoID: String, in dataManager: DataManager) -> Bookmark? {
|
||||||
|
dataManager.bookmarks(forVideoID: videoID).first {
|
||||||
|
recordMapper.toCKRecord(bookmark: $0).recordID.recordName == recordName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localRecentChannel(matching recordName: String, channelID: String, in dataManager: DataManager) -> RecentChannel? {
|
||||||
|
dataManager.recentChannelEntries(forChannelID: channelID).first {
|
||||||
|
recordMapper.toCKRecord(recentChannel: $0).recordID.recordName == recordName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localRecentPlaylist(matching recordName: String, playlistID: String, in dataManager: DataManager) -> RecentPlaylist? {
|
||||||
|
dataManager.recentPlaylistEntries(forPlaylistID: playlistID).first {
|
||||||
|
recordMapper.toCKRecord(recentPlaylist: $0).recordID.recordName == recordName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localChannelNotificationSettings(matching recordName: String, channelID: String, in dataManager: DataManager) -> ChannelNotificationSettings? {
|
||||||
|
dataManager.allChannelNotificationSettings(forChannelID: channelID).first {
|
||||||
|
recordMapper.toCKRecord(channelNotificationSettings: $0).recordID.recordName == recordName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds a CKRecord for the given record name from current local data.
|
/// Builds a CKRecord for the given record name from current local data.
|
||||||
/// The record name prefix identifies the entity type; the mapper re-derives
|
/// The record name prefix identifies the entity type; the local entity is
|
||||||
/// the scoped record name, which must match the requested one (a mismatch
|
/// matched on the full scoped record name (a mismatch means it belongs to
|
||||||
/// means the local entity belongs to a different source scope).
|
/// a different source scope).
|
||||||
private func materializeRecord(named recordName: String) async -> CKRecord? {
|
private func materializeRecord(named recordName: String) async -> CKRecord? {
|
||||||
guard let dataManager else { return nil }
|
guard let dataManager else { return nil }
|
||||||
|
|
||||||
func scopedMatch(_ record: CKRecord) -> CKRecord? {
|
|
||||||
record.recordID.recordName == recordName ? record : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if recordName.hasPrefix("sub-") {
|
if recordName.hasPrefix("sub-") {
|
||||||
guard canSyncSubscriptions else { return nil }
|
guard canSyncSubscriptions else { return nil }
|
||||||
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(4)))
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(4)))
|
||||||
guard let subscription = dataManager.subscription(for: channelID) else { return nil }
|
guard let subscription = localSubscription(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
||||||
return scopedMatch(recordMapper.toCKRecord(subscription: subscription))
|
return recordMapper.toCKRecord(subscription: subscription)
|
||||||
}
|
}
|
||||||
if recordName.hasPrefix("watch-") {
|
if recordName.hasPrefix("watch-") {
|
||||||
guard canSyncPlaybackHistory else { return nil }
|
guard canSyncPlaybackHistory else { return nil }
|
||||||
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(6)))
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(6)))
|
||||||
guard let entry = dataManager.watchEntry(for: videoID), shouldSyncWatchEntry(entry) else { return nil }
|
guard let entry = localWatchEntry(matching: recordName, videoID: videoID, in: dataManager),
|
||||||
return scopedMatch(recordMapper.toCKRecord(watchEntry: entry))
|
shouldSyncWatchEntry(entry) else { return nil }
|
||||||
|
return recordMapper.toCKRecord(watchEntry: entry)
|
||||||
}
|
}
|
||||||
if recordName.hasPrefix("bookmark-") {
|
if recordName.hasPrefix("bookmark-") {
|
||||||
guard canSyncBookmarks else { return nil }
|
guard canSyncBookmarks else { return nil }
|
||||||
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(9)))
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(9)))
|
||||||
guard let bookmark = dataManager.bookmark(for: videoID) else { return nil }
|
guard let bookmark = localBookmark(matching: recordName, videoID: videoID, in: dataManager) else { return nil }
|
||||||
return scopedMatch(recordMapper.toCKRecord(bookmark: bookmark))
|
return recordMapper.toCKRecord(bookmark: bookmark)
|
||||||
}
|
}
|
||||||
if recordName.hasPrefix("recent-channel-") {
|
if recordName.hasPrefix("recent-channel-") {
|
||||||
guard canSyncSearchHistory else { return nil }
|
guard canSyncSearchHistory else { return nil }
|
||||||
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(15)))
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(15)))
|
||||||
guard let recentChannel = dataManager.recentChannelEntry(forChannelID: channelID) else { return nil }
|
guard let recentChannel = localRecentChannel(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
||||||
return scopedMatch(recordMapper.toCKRecord(recentChannel: recentChannel))
|
return recordMapper.toCKRecord(recentChannel: recentChannel)
|
||||||
}
|
}
|
||||||
if recordName.hasPrefix("recent-playlist-") {
|
if recordName.hasPrefix("recent-playlist-") {
|
||||||
guard canSyncSearchHistory else { return nil }
|
guard canSyncSearchHistory else { return nil }
|
||||||
let playlistID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(16)))
|
let playlistID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(16)))
|
||||||
guard let recentPlaylist = dataManager.recentPlaylistEntry(forPlaylistID: playlistID) else { return nil }
|
guard let recentPlaylist = localRecentPlaylist(matching: recordName, playlistID: playlistID, in: dataManager) else { return nil }
|
||||||
return scopedMatch(recordMapper.toCKRecord(recentPlaylist: recentPlaylist))
|
return recordMapper.toCKRecord(recentPlaylist: recentPlaylist)
|
||||||
}
|
}
|
||||||
if recordName.hasPrefix("channel-notif-") {
|
if recordName.hasPrefix("channel-notif-") {
|
||||||
guard canSyncSubscriptions else { return nil }
|
guard canSyncSubscriptions else { return nil }
|
||||||
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(14)))
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(14)))
|
||||||
guard let settings = dataManager.channelNotificationSettings(for: channelID) else { return nil }
|
guard let settings = localChannelNotificationSettings(matching: recordName, channelID: channelID, in: dataManager) else { return nil }
|
||||||
return scopedMatch(recordMapper.toCKRecord(channelNotificationSettings: settings))
|
return recordMapper.toCKRecord(channelNotificationSettings: settings)
|
||||||
}
|
}
|
||||||
if recordName.hasPrefix("playlist-") {
|
if recordName.hasPrefix("playlist-") {
|
||||||
guard canSyncPlaylists,
|
guard canSyncPlaylists,
|
||||||
@@ -2019,8 +2057,10 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
guard canSyncSubscriptions else { return .success }
|
guard canSyncSubscriptions else { return .success }
|
||||||
let subscription = try recordMapper.toSubscription(from: record)
|
let subscription = try recordMapper.toSubscription(from: record)
|
||||||
|
|
||||||
// Check if exists locally
|
// Check if exists locally (same source scope only)
|
||||||
if let existing = dataManager.subscription(for: subscription.channelID) {
|
if let existing = localSubscription(matching: record.recordID.recordName, channelID: subscription.channelID, in: dataManager) {
|
||||||
|
let localWasNewer = existing.lastUpdatedAt > subscription.lastUpdatedAt
|
||||||
|
|
||||||
// Conflict - resolve it
|
// Conflict - resolve it
|
||||||
let localRecord = recordMapper.toCKRecord(subscription: existing)
|
let localRecord = recordMapper.toCKRecord(subscription: existing)
|
||||||
let resolved = await conflictResolver.resolveSubscriptionConflict(local: localRecord, server: record)
|
let resolved = await conflictResolver.resolveSubscriptionConflict(local: localRecord, server: record)
|
||||||
@@ -2042,6 +2082,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
|
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Merged subscription from iCloud (conflict resolved): \(subscription.channelID)")
|
LoggingService.shared.logCloudKit("Merged subscription from iCloud (conflict resolved): \(subscription.channelID)")
|
||||||
|
|
||||||
|
// Push the merge result back when local data won - the
|
||||||
|
// server still has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// New subscription from iCloud
|
// New subscription from iCloud
|
||||||
dataManager.insertSubscription(subscription)
|
dataManager.insertSubscription(subscription)
|
||||||
@@ -2066,8 +2112,10 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if exists locally
|
// Check if exists locally (same source scope only)
|
||||||
if let existing = dataManager.watchEntry(for: watchEntry.videoID) {
|
if let existing = localWatchEntry(matching: record.recordID.recordName, videoID: watchEntry.videoID, in: dataManager) {
|
||||||
|
let localWasNewer = existing.updatedAt > watchEntry.updatedAt
|
||||||
|
|
||||||
// Conflict - resolve it
|
// Conflict - resolve it
|
||||||
let localRecord = recordMapper.toCKRecord(watchEntry: existing)
|
let localRecord = recordMapper.toCKRecord(watchEntry: existing)
|
||||||
let resolved = await conflictResolver.resolveWatchEntryConflict(local: localRecord, server: record)
|
let resolved = await conflictResolver.resolveWatchEntryConflict(local: localRecord, server: record)
|
||||||
@@ -2092,6 +2140,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Merged watch entry from iCloud (conflict resolved): \(watchEntry.videoID)")
|
LoggingService.shared.logCloudKit("Merged watch entry from iCloud (conflict resolved): \(watchEntry.videoID)")
|
||||||
|
|
||||||
|
// Push the merge result back when local data won - the
|
||||||
|
// server still has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// New watch entry from iCloud
|
// New watch entry from iCloud
|
||||||
dataManager.insertWatchEntry(watchEntry)
|
dataManager.insertWatchEntry(watchEntry)
|
||||||
@@ -2106,8 +2160,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
guard canSyncBookmarks else { return .success }
|
guard canSyncBookmarks else { return .success }
|
||||||
let bookmark = try recordMapper.toBookmark(from: record)
|
let bookmark = try recordMapper.toBookmark(from: record)
|
||||||
|
|
||||||
// Check if exists locally
|
// Check if exists locally (same source scope only)
|
||||||
if let existing = dataManager.bookmark(for: bookmark.videoID) {
|
if let existing = localBookmark(matching: record.recordID.recordName, videoID: bookmark.videoID, in: dataManager) {
|
||||||
|
let localWasNewer = existing.createdAt > bookmark.createdAt
|
||||||
|
|| (existing.noteModifiedAt ?? .distantPast) > (bookmark.noteModifiedAt ?? .distantPast)
|
||||||
|
|| (existing.tagsModifiedAt ?? .distantPast) > (bookmark.tagsModifiedAt ?? .distantPast)
|
||||||
|
|
||||||
// Conflict - resolve it
|
// Conflict - resolve it
|
||||||
let localRecord = recordMapper.toCKRecord(bookmark: existing)
|
let localRecord = recordMapper.toCKRecord(bookmark: existing)
|
||||||
let resolved = await conflictResolver.resolveBookmarkConflict(local: localRecord, server: record)
|
let resolved = await conflictResolver.resolveBookmarkConflict(local: localRecord, server: record)
|
||||||
@@ -2136,6 +2194,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Merged bookmark from iCloud (conflict resolved): \(bookmark.videoID)")
|
LoggingService.shared.logCloudKit("Merged bookmark from iCloud (conflict resolved): \(bookmark.videoID)")
|
||||||
|
|
||||||
|
// Push the merge result back when local data won - the
|
||||||
|
// server still has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// New bookmark from iCloud
|
// New bookmark from iCloud
|
||||||
dataManager.insertBookmark(bookmark)
|
dataManager.insertBookmark(bookmark)
|
||||||
@@ -2170,6 +2234,8 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
|
|
||||||
LoggingService.shared.logCloudKit("Upgraded placeholder to real playlist: \(playlist.title)")
|
LoggingService.shared.logCloudKit("Upgraded placeholder to real playlist: \(playlist.title)")
|
||||||
} else {
|
} else {
|
||||||
|
let localWasNewer = existing.updatedAt > playlist.updatedAt
|
||||||
|
|
||||||
// Conflict - resolve it
|
// Conflict - resolve it
|
||||||
let localRecord = recordMapper.toCKRecord(playlist: existing)
|
let localRecord = recordMapper.toCKRecord(playlist: existing)
|
||||||
let resolved = await conflictResolver.resolveLocalPlaylistConflict(local: localRecord, server: record)
|
let resolved = await conflictResolver.resolveLocalPlaylistConflict(local: localRecord, server: record)
|
||||||
@@ -2186,6 +2252,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Merged playlist from iCloud (conflict resolved): \(playlist.title)")
|
LoggingService.shared.logCloudKit("Merged playlist from iCloud (conflict resolved): \(playlist.title)")
|
||||||
|
|
||||||
|
// Push the merge result back when local data won - the
|
||||||
|
// server still has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New playlist from iCloud
|
// New playlist from iCloud
|
||||||
@@ -2203,6 +2275,8 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
|
|
||||||
// Check if exists locally
|
// Check if exists locally
|
||||||
if let existing = dataManager.playlistItem(forID: item.id) {
|
if let existing = dataManager.playlistItem(forID: item.id) {
|
||||||
|
let localWasNewer = existing.addedAt > item.addedAt
|
||||||
|
|
||||||
// Conflict - resolve it
|
// Conflict - resolve it
|
||||||
let localRecord = recordMapper.toCKRecord(playlistItem: existing)
|
let localRecord = recordMapper.toCKRecord(playlistItem: existing)
|
||||||
let resolved = await conflictResolver.resolveLocalPlaylistItemConflict(local: localRecord, server: record)
|
let resolved = await conflictResolver.resolveLocalPlaylistItemConflict(local: localRecord, server: record)
|
||||||
@@ -2220,6 +2294,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
dataManager.save()
|
dataManager.save()
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Merged playlist item from iCloud (conflict resolved): \(item.videoID)")
|
LoggingService.shared.logCloudKit("Merged playlist item from iCloud (conflict resolved): \(item.videoID)")
|
||||||
|
|
||||||
|
// Push the merge result back when local data won - the
|
||||||
|
// server still has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// New item from iCloud - need to find parent playlist
|
// New item from iCloud - need to find parent playlist
|
||||||
if let playlistID = playlistID,
|
if let playlistID = playlistID,
|
||||||
@@ -2248,6 +2328,8 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
|
|
||||||
// Check if exists locally
|
// Check if exists locally
|
||||||
if let existing = dataManager.searchHistoryEntry(forID: searchHistory.id) {
|
if let existing = dataManager.searchHistoryEntry(forID: searchHistory.id) {
|
||||||
|
let localWasNewer = existing.searchedAt > searchHistory.searchedAt
|
||||||
|
|
||||||
// Conflict - resolve it
|
// Conflict - resolve it
|
||||||
let localRecord = recordMapper.toCKRecord(searchHistory: existing)
|
let localRecord = recordMapper.toCKRecord(searchHistory: existing)
|
||||||
let resolved = await conflictResolver.resolveSearchHistoryConflict(local: localRecord, server: record)
|
let resolved = await conflictResolver.resolveSearchHistoryConflict(local: localRecord, server: record)
|
||||||
@@ -2263,6 +2345,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Merged search history from iCloud (conflict resolved): \(searchHistory.query)")
|
LoggingService.shared.logCloudKit("Merged search history from iCloud (conflict resolved): \(searchHistory.query)")
|
||||||
|
|
||||||
|
// Push the merge result back when local data won - the
|
||||||
|
// server still has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// New search history from iCloud
|
// New search history from iCloud
|
||||||
dataManager.insertSearchHistory(searchHistory)
|
dataManager.insertSearchHistory(searchHistory)
|
||||||
@@ -2277,8 +2365,10 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
guard canSyncSearchHistory else { return .success }
|
guard canSyncSearchHistory else { return .success }
|
||||||
let recentChannel = try recordMapper.toRecentChannel(from: record)
|
let recentChannel = try recordMapper.toRecentChannel(from: record)
|
||||||
|
|
||||||
// Check if exists locally
|
// Check if exists locally (same source scope only)
|
||||||
if let existing = dataManager.recentChannelEntry(forChannelID: recentChannel.channelID) {
|
if let existing = localRecentChannel(matching: record.recordID.recordName, channelID: recentChannel.channelID, in: dataManager) {
|
||||||
|
let localWasNewer = existing.visitedAt > recentChannel.visitedAt
|
||||||
|
|
||||||
// Conflict - resolve it
|
// Conflict - resolve it
|
||||||
let localRecord = recordMapper.toCKRecord(recentChannel: existing)
|
let localRecord = recordMapper.toCKRecord(recentChannel: existing)
|
||||||
let resolved = await conflictResolver.resolveRecentChannelConflict(local: localRecord, server: record)
|
let resolved = await conflictResolver.resolveRecentChannelConflict(local: localRecord, server: record)
|
||||||
@@ -2299,6 +2389,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Merged recent channel from iCloud (conflict resolved): \(recentChannel.channelID)")
|
LoggingService.shared.logCloudKit("Merged recent channel from iCloud (conflict resolved): \(recentChannel.channelID)")
|
||||||
|
|
||||||
|
// Push the merge result back when local data won - the
|
||||||
|
// server still has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// New recent channel from iCloud
|
// New recent channel from iCloud
|
||||||
dataManager.insertRecentChannel(recentChannel)
|
dataManager.insertRecentChannel(recentChannel)
|
||||||
@@ -2313,8 +2409,10 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
guard canSyncSearchHistory else { return .success }
|
guard canSyncSearchHistory else { return .success }
|
||||||
let recentPlaylist = try recordMapper.toRecentPlaylist(from: record)
|
let recentPlaylist = try recordMapper.toRecentPlaylist(from: record)
|
||||||
|
|
||||||
// Check if exists locally
|
// Check if exists locally (same source scope only)
|
||||||
if let existing = dataManager.recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) {
|
if let existing = localRecentPlaylist(matching: record.recordID.recordName, playlistID: recentPlaylist.playlistID, in: dataManager) {
|
||||||
|
let localWasNewer = existing.visitedAt > recentPlaylist.visitedAt
|
||||||
|
|
||||||
// Conflict - resolve it
|
// Conflict - resolve it
|
||||||
let localRecord = recordMapper.toCKRecord(recentPlaylist: existing)
|
let localRecord = recordMapper.toCKRecord(recentPlaylist: existing)
|
||||||
let resolved = await conflictResolver.resolveRecentPlaylistConflict(local: localRecord, server: record)
|
let resolved = await conflictResolver.resolveRecentPlaylistConflict(local: localRecord, server: record)
|
||||||
@@ -2335,6 +2433,12 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Merged recent playlist from iCloud (conflict resolved): \(recentPlaylist.playlistID)")
|
LoggingService.shared.logCloudKit("Merged recent playlist from iCloud (conflict resolved): \(recentPlaylist.playlistID)")
|
||||||
|
|
||||||
|
// Push the merge result back when local data won - the
|
||||||
|
// server still has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// New recent playlist from iCloud
|
// New recent playlist from iCloud
|
||||||
dataManager.insertRecentPlaylist(recentPlaylist)
|
dataManager.insertRecentPlaylist(recentPlaylist)
|
||||||
@@ -2348,10 +2452,18 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
case RecordType.channelNotificationSettings:
|
case RecordType.channelNotificationSettings:
|
||||||
guard canSyncSubscriptions else { return .success }
|
guard canSyncSubscriptions else { return .success }
|
||||||
let settings = try recordMapper.toChannelNotificationSettings(from: record)
|
let settings = try recordMapper.toChannelNotificationSettings(from: record)
|
||||||
|
|
||||||
// Use upsert which handles conflict resolution based on updatedAt
|
// Upsert keeps whichever side has the newer updatedAt
|
||||||
|
let existingSettings = dataManager.channelNotificationSettings(for: settings.channelID)
|
||||||
|
let localWasNewer = existingSettings.map { $0.updatedAt > settings.updatedAt } ?? false
|
||||||
dataManager.upsertChannelNotificationSettings(settings)
|
dataManager.upsertChannelNotificationSettings(settings)
|
||||||
|
|
||||||
|
// Push the local version back when it won - the server still
|
||||||
|
// has the older version
|
||||||
|
if localWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Applied channel notification settings from iCloud: \(settings.channelID)")
|
LoggingService.shared.logCloudKit("Applied channel notification settings from iCloud: \(settings.channelID)")
|
||||||
|
|
||||||
case RecordType.controlsPreset:
|
case RecordType.controlsPreset:
|
||||||
@@ -2366,8 +2478,18 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
|
|
||||||
// Use shared layout service if available, fallback to new instance
|
// Use shared layout service if available, fallback to new instance
|
||||||
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
let layoutService = playerControlsLayoutService ?? PlayerControlsLayoutService()
|
||||||
|
|
||||||
|
// importPreset keeps whichever side has the newer updatedAt
|
||||||
|
let localPresets = await layoutService.presetsForSync()
|
||||||
|
let localPresetWasNewer = localPresets.first(where: { $0.id == preset.id }).map { $0.updatedAt > preset.updatedAt } ?? false
|
||||||
try await layoutService.importPreset(preset)
|
try await layoutService.importPreset(preset)
|
||||||
|
|
||||||
|
// Push the local version back when it won - the server still
|
||||||
|
// has the older version
|
||||||
|
if localPresetWasNewer {
|
||||||
|
addPendingChanges([.saveRecord(record.recordID)])
|
||||||
|
}
|
||||||
|
|
||||||
LoggingService.shared.logCloudKit("Applied controls preset from iCloud: \(preset.name)")
|
LoggingService.shared.logCloudKit("Applied controls preset from iCloud: \(preset.name)")
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -2388,28 +2510,42 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a remote deletion to local SwiftData.
|
/// Apply a remote deletion to local SwiftData.
|
||||||
|
/// Entities are matched on the full scoped record name, so a deletion in
|
||||||
|
/// one source scope cannot remove same-ID entities from other scopes, and
|
||||||
|
/// nothing is echoed back to CloudKit.
|
||||||
private func applyRemoteDeletion(_ recordID: CKRecord.ID, to dataManager: DataManager) async {
|
private func applyRemoteDeletion(_ recordID: CKRecord.ID, to dataManager: DataManager) async {
|
||||||
let recordName = recordID.recordName
|
let recordName = recordID.recordName
|
||||||
|
|
||||||
// Parse record type from record name and strip scope suffix for bare ID lookup
|
|
||||||
if recordName.hasPrefix("sub-") {
|
if recordName.hasPrefix("sub-") {
|
||||||
guard canSyncSubscriptions else { return }
|
guard canSyncSubscriptions else { return }
|
||||||
let rest = String(recordName.dropFirst(4))
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(4)))
|
||||||
let channelID = SyncableRecordType.extractBareID(from: rest)
|
if let subscription = localSubscription(matching: recordName, channelID: channelID, in: dataManager) {
|
||||||
dataManager.unsubscribe(from: channelID)
|
dataManager.deleteSubscription(subscription)
|
||||||
LoggingService.shared.logCloudKit("Deleted subscription from iCloud: \(channelID)")
|
dataManager.save()
|
||||||
|
let change = SubscriptionChange(addedSubscriptions: [], removedChannelIDs: [channelID])
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .subscriptionsDidChange,
|
||||||
|
object: nil,
|
||||||
|
userInfo: [SubscriptionChange.userInfoKey: change]
|
||||||
|
)
|
||||||
|
LoggingService.shared.logCloudKit("Deleted subscription from iCloud: \(channelID)")
|
||||||
|
}
|
||||||
} else if recordName.hasPrefix("watch-") {
|
} else if recordName.hasPrefix("watch-") {
|
||||||
guard canSyncPlaybackHistory else { return }
|
guard canSyncPlaybackHistory else { return }
|
||||||
let rest = String(recordName.dropFirst(6))
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(6)))
|
||||||
let videoID = SyncableRecordType.extractBareID(from: rest)
|
if let entry = localWatchEntry(matching: recordName, videoID: videoID, in: dataManager) {
|
||||||
dataManager.removeFromHistory(videoID: videoID)
|
dataManager.deleteWatchEntry(entry)
|
||||||
LoggingService.shared.logCloudKit("Deleted watch entry from iCloud: \(videoID)")
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
||||||
|
LoggingService.shared.logCloudKit("Deleted watch entry from iCloud: \(videoID)")
|
||||||
|
}
|
||||||
} else if recordName.hasPrefix("bookmark-") {
|
} else if recordName.hasPrefix("bookmark-") {
|
||||||
guard canSyncBookmarks else { return }
|
guard canSyncBookmarks else { return }
|
||||||
let rest = String(recordName.dropFirst(9))
|
let videoID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(9)))
|
||||||
let videoID = SyncableRecordType.extractBareID(from: rest)
|
if let bookmark = localBookmark(matching: recordName, videoID: videoID, in: dataManager) {
|
||||||
dataManager.removeBookmark(for: videoID)
|
dataManager.deleteBookmark(bookmark)
|
||||||
LoggingService.shared.logCloudKit("Deleted bookmark from iCloud: \(videoID)")
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
||||||
|
LoggingService.shared.logCloudKit("Deleted bookmark from iCloud: \(videoID)")
|
||||||
|
}
|
||||||
} else if recordName.hasPrefix("playlist-") {
|
} else if recordName.hasPrefix("playlist-") {
|
||||||
guard canSyncPlaylists else { return }
|
guard canSyncPlaylists else { return }
|
||||||
let playlistIDString = String(recordName.dropFirst(9))
|
let playlistIDString = String(recordName.dropFirst(9))
|
||||||
@@ -2436,26 +2572,25 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
|||||||
}
|
}
|
||||||
} else if recordName.hasPrefix("recent-channel-") {
|
} else if recordName.hasPrefix("recent-channel-") {
|
||||||
guard canSyncSearchHistory else { return }
|
guard canSyncSearchHistory else { return }
|
||||||
let rest = String(recordName.dropFirst(15))
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(15)))
|
||||||
let channelID = SyncableRecordType.extractBareID(from: rest)
|
if let recentChannel = localRecentChannel(matching: recordName, channelID: channelID, in: dataManager) {
|
||||||
if let recentChannel = dataManager.recentChannelEntry(forChannelID: channelID) {
|
dataManager.deleteRecentChannelEntry(recentChannel)
|
||||||
dataManager.deleteRecentChannel(recentChannel)
|
|
||||||
LoggingService.shared.logCloudKit("Deleted recent channel from iCloud: \(channelID)")
|
LoggingService.shared.logCloudKit("Deleted recent channel from iCloud: \(channelID)")
|
||||||
}
|
}
|
||||||
} else if recordName.hasPrefix("recent-playlist-") {
|
} else if recordName.hasPrefix("recent-playlist-") {
|
||||||
guard canSyncSearchHistory else { return }
|
guard canSyncSearchHistory else { return }
|
||||||
let rest = String(recordName.dropFirst(16))
|
let playlistID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(16)))
|
||||||
let playlistID = SyncableRecordType.extractBareID(from: rest)
|
if let recentPlaylist = localRecentPlaylist(matching: recordName, playlistID: playlistID, in: dataManager) {
|
||||||
if let recentPlaylist = dataManager.recentPlaylistEntry(forPlaylistID: playlistID) {
|
dataManager.deleteRecentPlaylistEntry(recentPlaylist)
|
||||||
dataManager.deleteRecentPlaylist(recentPlaylist)
|
|
||||||
LoggingService.shared.logCloudKit("Deleted recent playlist from iCloud: \(playlistID)")
|
LoggingService.shared.logCloudKit("Deleted recent playlist from iCloud: \(playlistID)")
|
||||||
}
|
}
|
||||||
} else if recordName.hasPrefix("channel-notif-") {
|
} else if recordName.hasPrefix("channel-notif-") {
|
||||||
guard canSyncSubscriptions else { return }
|
guard canSyncSubscriptions else { return }
|
||||||
let rest = String(recordName.dropFirst(14))
|
let channelID = SyncableRecordType.extractBareID(from: String(recordName.dropFirst(14)))
|
||||||
let channelID = SyncableRecordType.extractBareID(from: rest)
|
if let settings = localChannelNotificationSettings(matching: recordName, channelID: channelID, in: dataManager) {
|
||||||
dataManager.deleteNotificationSettings(for: channelID)
|
dataManager.deleteChannelNotificationSettings(settings)
|
||||||
LoggingService.shared.logCloudKit("Deleted channel notification settings from iCloud: \(channelID)")
|
LoggingService.shared.logCloudKit("Deleted channel notification settings from iCloud: \(channelID)")
|
||||||
|
}
|
||||||
} else if recordName.hasPrefix("controls-") {
|
} else if recordName.hasPrefix("controls-") {
|
||||||
guard canSyncControlsPresets else { return }
|
guard canSyncControlsPresets else { return }
|
||||||
let presetIDString = String(recordName.dropFirst(9)) // Remove "controls-" prefix
|
let presetIDString = String(recordName.dropFirst(9)) // Remove "controls-" prefix
|
||||||
|
|||||||
Reference in New Issue
Block a user