mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
341
Yattee/Services/CloudKit/CloudKitConflictResolver.swift
Normal file
341
Yattee/Services/CloudKit/CloudKitConflictResolver.swift
Normal file
@@ -0,0 +1,341 @@
|
||||
//
|
||||
// CloudKitConflictResolver.swift
|
||||
// Yattee
|
||||
//
|
||||
// Handles conflict resolution when local and remote records differ.
|
||||
//
|
||||
|
||||
import CloudKit
|
||||
import Foundation
|
||||
|
||||
/// Resolves conflicts between local and remote CloudKit records.
|
||||
actor CloudKitConflictResolver {
|
||||
// MARK: - Subscription Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server subscription records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Keep record with most recent `lastUpdatedAt`
|
||||
/// - EXCEPT: always preserve local `notificationsEnabled` preference
|
||||
func resolveSubscriptionConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localDate = (local["lastUpdatedAt"] as? Date) ?? Date.distantPast
|
||||
let serverDate = (server["lastUpdatedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
// This is required for CloudKit to accept the update
|
||||
let resolved = server
|
||||
|
||||
// If local is newer, copy its fields to server record
|
||||
if localDate > serverDate {
|
||||
resolved["name"] = local["name"]
|
||||
resolved["channelDescription"] = local["channelDescription"]
|
||||
resolved["subscriberCount"] = local["subscriberCount"]
|
||||
resolved["avatarURLString"] = local["avatarURLString"]
|
||||
resolved["bannerURLString"] = local["bannerURLString"]
|
||||
resolved["isVerified"] = local["isVerified"]
|
||||
resolved["lastUpdatedAt"] = local["lastUpdatedAt"]
|
||||
resolved["providerName"] = local["providerName"]
|
||||
}
|
||||
|
||||
// But always preserve local notification preference
|
||||
// (user's device-specific setting should not be overwritten by other devices)
|
||||
if let localNotifications = local["notificationsEnabled"] {
|
||||
resolved["notificationsEnabled"] = localNotifications
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - WatchEntry Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server watch entry records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Use most recent updatedAt for watch progress (respects "mark as unwatched")
|
||||
/// - Use most recent updatedAt for metadata fields
|
||||
/// - Preserve finishedAt from whichever record marked it finished
|
||||
func resolveWatchEntryConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localSeconds = (local["watchedSeconds"] as? Double) ?? 0
|
||||
let serverSeconds = (server["watchedSeconds"] as? Double) ?? 0
|
||||
|
||||
let localFinished = ((local["isFinished"] as? Int64) ?? 0) == 1
|
||||
let serverFinished = ((server["isFinished"] as? Int64) ?? 0) == 1
|
||||
|
||||
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
|
||||
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
// This is required for CloudKit to accept the update
|
||||
let resolved = server
|
||||
|
||||
// Copy metadata from local if it's newer
|
||||
if localUpdated > serverUpdated {
|
||||
resolved["title"] = local["title"]
|
||||
resolved["authorName"] = local["authorName"]
|
||||
resolved["authorID"] = local["authorID"]
|
||||
resolved["duration"] = local["duration"]
|
||||
resolved["thumbnailURLString"] = local["thumbnailURLString"]
|
||||
}
|
||||
|
||||
// Use watch progress from the most recently updated record
|
||||
// This respects user intent - if they marked as unwatched, that action wins
|
||||
if localUpdated > serverUpdated {
|
||||
resolved["watchedSeconds"] = localSeconds as CKRecordValue
|
||||
resolved["isFinished"] = (localFinished ? 1 : 0) as CKRecordValue
|
||||
resolved["finishedAt"] = local["finishedAt"]
|
||||
} else {
|
||||
resolved["watchedSeconds"] = serverSeconds as CKRecordValue
|
||||
resolved["isFinished"] = (serverFinished ? 1 : 0) as CKRecordValue
|
||||
resolved["finishedAt"] = server["finishedAt"]
|
||||
}
|
||||
|
||||
// Use most recent updatedAt
|
||||
resolved["updatedAt"] = max(localUpdated, serverUpdated) as CKRecordValue
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - Bookmark Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server bookmark records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Keep most recent createdAt (newest wins)
|
||||
/// - Preserve local note if it exists
|
||||
/// - Keep local sortOrder (user's manual ordering)
|
||||
func resolveBookmarkConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localCreated = (local["createdAt"] as? Date) ?? Date.distantPast
|
||||
let serverCreated = (server["createdAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
let resolved = server
|
||||
|
||||
// If local is newer, copy its metadata to server record
|
||||
if localCreated > serverCreated {
|
||||
resolved["title"] = local["title"]
|
||||
resolved["authorName"] = local["authorName"]
|
||||
resolved["authorID"] = local["authorID"]
|
||||
resolved["duration"] = local["duration"]
|
||||
resolved["thumbnailURLString"] = local["thumbnailURLString"]
|
||||
resolved["isLive"] = local["isLive"]
|
||||
resolved["viewCount"] = local["viewCount"]
|
||||
resolved["publishedAt"] = local["publishedAt"]
|
||||
resolved["publishedText"] = local["publishedText"]
|
||||
resolved["createdAt"] = local["createdAt"]
|
||||
}
|
||||
|
||||
// Resolve note based on timestamp (most recent wins)
|
||||
let localNoteModified = local["noteModifiedAt"] as? Date ?? Date.distantPast
|
||||
let serverNoteModified = server["noteModifiedAt"] as? Date ?? Date.distantPast
|
||||
|
||||
if localNoteModified >= serverNoteModified {
|
||||
// Local note is newer or equal - use local
|
||||
resolved["note"] = local["note"]
|
||||
resolved["noteModifiedAt"] = local["noteModifiedAt"]
|
||||
} else {
|
||||
// Server note is newer - use server (already in resolved)
|
||||
// No need to set, already using server record
|
||||
}
|
||||
|
||||
// Resolve tags based on timestamp (most recent wins)
|
||||
let localTagsModified = local["tagsModifiedAt"] as? Date ?? Date.distantPast
|
||||
let serverTagsModified = server["tagsModifiedAt"] as? Date ?? Date.distantPast
|
||||
|
||||
if localTagsModified >= serverTagsModified {
|
||||
// Local tags are newer or equal - use local
|
||||
resolved["tags"] = local["tags"]
|
||||
resolved["tagsModifiedAt"] = local["tagsModifiedAt"]
|
||||
} else {
|
||||
// Server tags are newer - use server (already in resolved)
|
||||
// No need to set, already using server record
|
||||
}
|
||||
|
||||
// Always preserve local sortOrder (user's manual ordering)
|
||||
if let localSortOrder = local["sortOrder"] {
|
||||
resolved["sortOrder"] = localSortOrder
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - Playlist Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server playlist records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Keep most recent updatedAt for metadata (title, description)
|
||||
/// - Preserve server recordChangeTag
|
||||
func resolveLocalPlaylistConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
|
||||
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
let resolved = server
|
||||
|
||||
// If local is newer, copy its metadata to server record
|
||||
if localUpdated > serverUpdated {
|
||||
resolved["title"] = local["title"]
|
||||
resolved["playlistDescription"] = local["playlistDescription"]
|
||||
resolved["updatedAt"] = local["updatedAt"]
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
/// Resolves a conflict between local and server playlist item records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Keep most recent addedAt for metadata
|
||||
/// - Preserve local sortOrder (user's ordering)
|
||||
func resolveLocalPlaylistItemConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localAdded = (local["addedAt"] as? Date) ?? Date.distantPast
|
||||
let serverAdded = (server["addedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
let resolved = server
|
||||
|
||||
// If local is newer, copy its metadata to server record
|
||||
if localAdded > serverAdded {
|
||||
resolved["title"] = local["title"]
|
||||
resolved["authorName"] = local["authorName"]
|
||||
resolved["authorID"] = local["authorID"]
|
||||
resolved["duration"] = local["duration"]
|
||||
resolved["thumbnailURLString"] = local["thumbnailURLString"]
|
||||
resolved["isLive"] = local["isLive"]
|
||||
}
|
||||
|
||||
// Always preserve local sortOrder (user's manual ordering)
|
||||
if let localSortOrder = local["sortOrder"] {
|
||||
resolved["sortOrder"] = localSortOrder
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - SearchHistory Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server search history records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Keep most recent searchedAt (newest wins)
|
||||
/// - Preserve server recordChangeTag
|
||||
func resolveSearchHistoryConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localSearched = (local["searchedAt"] as? Date) ?? Date.distantPast
|
||||
let serverSearched = (server["searchedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
let resolved = server
|
||||
|
||||
// If local is newer, copy its fields to server record
|
||||
if localSearched > serverSearched {
|
||||
resolved["query"] = local["query"]
|
||||
resolved["searchedAt"] = local["searchedAt"]
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - RecentChannel Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server recent channel records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Keep most recent visitedAt (newest wins)
|
||||
/// - Preserve server recordChangeTag
|
||||
func resolveRecentChannelConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localVisited = (local["visitedAt"] as? Date) ?? Date.distantPast
|
||||
let serverVisited = (server["visitedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
let resolved = server
|
||||
|
||||
// If local is newer, copy its fields to server record
|
||||
if localVisited > serverVisited {
|
||||
resolved["name"] = local["name"]
|
||||
resolved["avatarURLString"] = local["avatarURLString"]
|
||||
resolved["providerName"] = local["providerName"]
|
||||
resolved["visitedAt"] = local["visitedAt"]
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - RecentPlaylist Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server recent playlist records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Keep most recent visitedAt (newest wins)
|
||||
/// - Preserve server recordChangeTag
|
||||
func resolveRecentPlaylistConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localVisited = (local["visitedAt"] as? Date) ?? Date.distantPast
|
||||
let serverVisited = (server["visitedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
let resolved = server
|
||||
|
||||
// If local is newer, copy its fields to server record
|
||||
if localVisited > serverVisited {
|
||||
resolved["title"] = local["title"]
|
||||
resolved["authorName"] = local["authorName"]
|
||||
resolved["authorID"] = local["authorID"]
|
||||
resolved["thumbnailURLString"] = local["thumbnailURLString"]
|
||||
resolved["videoCount"] = local["videoCount"]
|
||||
resolved["providerName"] = local["providerName"]
|
||||
resolved["visitedAt"] = local["visitedAt"]
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
// MARK: - LayoutPreset Conflict Resolution
|
||||
|
||||
/// Resolves a conflict between local and server layout preset records.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Last write wins based on updatedAt timestamp
|
||||
/// - Preserve server recordChangeTag
|
||||
func resolveLayoutPresetConflict(
|
||||
local: CKRecord,
|
||||
server: CKRecord
|
||||
) -> CKRecord {
|
||||
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
|
||||
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
|
||||
|
||||
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
||||
let resolved = server
|
||||
|
||||
// If local is newer, copy its fields to server record
|
||||
if localUpdated > serverUpdated {
|
||||
resolved["name"] = local["name"]
|
||||
resolved["updatedAt"] = local["updatedAt"]
|
||||
resolved["layoutJSON"] = local["layoutJSON"]
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
883
Yattee/Services/CloudKit/CloudKitRecordMapper.swift
Normal file
883
Yattee/Services/CloudKit/CloudKitRecordMapper.swift
Normal file
@@ -0,0 +1,883 @@
|
||||
//
|
||||
// CloudKitRecordMapper.swift
|
||||
// Yattee
|
||||
//
|
||||
// Maps SwiftData models to/from CloudKit CKRecords.
|
||||
//
|
||||
|
||||
import CloudKit
|
||||
import Foundation
|
||||
|
||||
/// Maps SwiftData models to CloudKit records and vice versa.
|
||||
actor CloudKitRecordMapper {
|
||||
private let zone: CKRecordZone
|
||||
|
||||
/// Current schema version for CloudKit records.
|
||||
/// Increment this when record structure changes.
|
||||
private let currentSchemaVersion: Int64 = 2
|
||||
|
||||
init(zone: CKRecordZone) {
|
||||
self.zone = zone
|
||||
}
|
||||
|
||||
// MARK: - Subscription Mapping
|
||||
|
||||
/// Converts a Subscription to a CKRecord.
|
||||
func toCKRecord(subscription: Subscription) -> CKRecord {
|
||||
let scope = SourceScope.from(
|
||||
sourceRawValue: subscription.sourceRawValue,
|
||||
globalProvider: subscription.providerName,
|
||||
instanceURLString: subscription.instanceURLString,
|
||||
externalExtractor: nil
|
||||
)
|
||||
let recordID = SyncableRecordType.subscription(channelID: subscription.channelID, scope: scope).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.subscription, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
// Channel Identity
|
||||
record["channelID"] = subscription.channelID as CKRecordValue
|
||||
record["sourceRawValue"] = subscription.sourceRawValue as CKRecordValue
|
||||
record["instanceURLString"] = subscription.instanceURLString as CKRecordValue?
|
||||
|
||||
// Channel Metadata
|
||||
record["name"] = subscription.name as CKRecordValue
|
||||
record["channelDescription"] = subscription.channelDescription as CKRecordValue?
|
||||
record["subscriberCount"] = subscription.subscriberCount.map { Int64($0) } as CKRecordValue?
|
||||
record["avatarURLString"] = subscription.avatarURLString as CKRecordValue?
|
||||
record["bannerURLString"] = subscription.bannerURLString as CKRecordValue?
|
||||
record["isVerified"] = (subscription.isVerified ? 1 : 0) as CKRecordValue
|
||||
|
||||
// Subscription Metadata
|
||||
record["subscribedAt"] = subscription.subscribedAt as CKRecordValue
|
||||
record["lastUpdatedAt"] = subscription.lastUpdatedAt as CKRecordValue
|
||||
record["providerName"] = subscription.providerName as CKRecordValue?
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a Subscription from a CKRecord.
|
||||
func toSubscription(from record: CKRecord) throws -> Subscription {
|
||||
let recordType = RecordType.subscription
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let channelID = record["channelID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "channelID", recordType: recordType)
|
||||
}
|
||||
guard let sourceRawValue = record["sourceRawValue"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
|
||||
}
|
||||
guard let name = record["name"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "name", recordType: recordType)
|
||||
}
|
||||
guard let subscribedAt = record["subscribedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "subscribedAt", recordType: recordType)
|
||||
}
|
||||
guard let lastUpdatedAt = record["lastUpdatedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "lastUpdatedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
// Create subscription
|
||||
let subscription = Subscription(
|
||||
channelID: channelID,
|
||||
sourceRawValue: sourceRawValue,
|
||||
instanceURLString: record["instanceURLString"] as? String,
|
||||
name: name,
|
||||
channelDescription: record["channelDescription"] as? String,
|
||||
subscriberCount: (record["subscriberCount"] as? Int64).map(Int.init),
|
||||
avatarURLString: record["avatarURLString"] as? String,
|
||||
bannerURLString: record["bannerURLString"] as? String,
|
||||
isVerified: (record["isVerified"] as? Int64) == 1
|
||||
)
|
||||
|
||||
// Set timestamps
|
||||
subscription.subscribedAt = subscribedAt
|
||||
subscription.lastUpdatedAt = lastUpdatedAt
|
||||
subscription.providerName = record["providerName"] as? String
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
// MARK: - WatchEntry Mapping
|
||||
|
||||
/// Converts a WatchEntry to a CKRecord.
|
||||
func toCKRecord(watchEntry: WatchEntry) -> CKRecord {
|
||||
let scope = SourceScope.from(
|
||||
sourceRawValue: watchEntry.sourceRawValue,
|
||||
globalProvider: watchEntry.globalProvider,
|
||||
instanceURLString: watchEntry.instanceURLString,
|
||||
externalExtractor: watchEntry.externalExtractor
|
||||
)
|
||||
let recordID = SyncableRecordType.watchEntry(videoID: watchEntry.videoID, scope: scope).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.watchEntry, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
// Video Identity
|
||||
record["videoID"] = watchEntry.videoID as CKRecordValue
|
||||
record["sourceRawValue"] = watchEntry.sourceRawValue as CKRecordValue
|
||||
record["globalProvider"] = watchEntry.globalProvider as CKRecordValue?
|
||||
record["instanceURLString"] = watchEntry.instanceURLString as CKRecordValue?
|
||||
record["peertubeUUID"] = watchEntry.peertubeUUID as CKRecordValue?
|
||||
record["externalExtractor"] = watchEntry.externalExtractor as CKRecordValue?
|
||||
record["externalURLString"] = watchEntry.externalURLString as CKRecordValue?
|
||||
|
||||
// Video Metadata (cached)
|
||||
record["title"] = watchEntry.title as CKRecordValue
|
||||
record["authorName"] = watchEntry.authorName as CKRecordValue
|
||||
record["authorID"] = watchEntry.authorID as CKRecordValue
|
||||
record["duration"] = watchEntry.duration as CKRecordValue
|
||||
record["thumbnailURLString"] = watchEntry.thumbnailURLString as CKRecordValue?
|
||||
|
||||
// Watch Progress
|
||||
record["watchedSeconds"] = watchEntry.watchedSeconds as CKRecordValue
|
||||
record["isFinished"] = (watchEntry.isFinished ? 1 : 0) as CKRecordValue
|
||||
record["finishedAt"] = watchEntry.finishedAt as CKRecordValue?
|
||||
|
||||
// Timestamps
|
||||
record["createdAt"] = watchEntry.createdAt as CKRecordValue
|
||||
record["updatedAt"] = watchEntry.updatedAt as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a WatchEntry from a CKRecord.
|
||||
func toWatchEntry(from record: CKRecord) throws -> WatchEntry {
|
||||
let recordType = RecordType.watchEntry
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let videoID = record["videoID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "videoID", recordType: recordType)
|
||||
}
|
||||
guard let sourceRawValue = record["sourceRawValue"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
|
||||
}
|
||||
guard let title = record["title"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
|
||||
}
|
||||
guard let authorName = record["authorName"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "authorName", recordType: recordType)
|
||||
}
|
||||
guard let authorID = record["authorID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "authorID", recordType: recordType)
|
||||
}
|
||||
guard let duration = record["duration"] as? Double else {
|
||||
throw CloudKitError.missingRequiredField(field: "duration", recordType: recordType)
|
||||
}
|
||||
guard let watchedSeconds = record["watchedSeconds"] as? Double else {
|
||||
throw CloudKitError.missingRequiredField(field: "watchedSeconds", recordType: recordType)
|
||||
}
|
||||
guard let isFinishedInt = record["isFinished"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "isFinished", recordType: recordType)
|
||||
}
|
||||
guard let createdAt = record["createdAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "createdAt", recordType: recordType)
|
||||
}
|
||||
guard let updatedAt = record["updatedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
// Create watch entry
|
||||
let watchEntry = WatchEntry(
|
||||
videoID: videoID,
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: record["globalProvider"] as? String,
|
||||
instanceURLString: record["instanceURLString"] as? String,
|
||||
peertubeUUID: record["peertubeUUID"] as? String,
|
||||
externalExtractor: record["externalExtractor"] as? String,
|
||||
externalURLString: record["externalURLString"] as? String,
|
||||
title: title,
|
||||
authorName: authorName,
|
||||
authorID: authorID,
|
||||
duration: duration,
|
||||
thumbnailURLString: record["thumbnailURLString"] as? String,
|
||||
watchedSeconds: watchedSeconds,
|
||||
isFinished: isFinishedInt == 1
|
||||
)
|
||||
|
||||
// Set timestamps
|
||||
watchEntry.createdAt = createdAt
|
||||
watchEntry.updatedAt = updatedAt
|
||||
watchEntry.finishedAt = record["finishedAt"] as? Date
|
||||
|
||||
return watchEntry
|
||||
}
|
||||
|
||||
// MARK: - Bookmark Mapping
|
||||
|
||||
/// Converts a Bookmark to a CKRecord.
|
||||
func toCKRecord(bookmark: Bookmark) -> CKRecord {
|
||||
let scope = SourceScope.from(
|
||||
sourceRawValue: bookmark.sourceRawValue,
|
||||
globalProvider: bookmark.globalProvider,
|
||||
instanceURLString: bookmark.instanceURLString,
|
||||
externalExtractor: bookmark.externalExtractor
|
||||
)
|
||||
let recordID = SyncableRecordType.bookmark(videoID: bookmark.videoID, scope: scope).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.bookmark, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
// Video Identity
|
||||
record["videoID"] = bookmark.videoID as CKRecordValue
|
||||
record["sourceRawValue"] = bookmark.sourceRawValue as CKRecordValue
|
||||
record["globalProvider"] = bookmark.globalProvider as CKRecordValue?
|
||||
record["instanceURLString"] = bookmark.instanceURLString as CKRecordValue?
|
||||
record["peertubeUUID"] = bookmark.peertubeUUID as CKRecordValue?
|
||||
record["externalExtractor"] = bookmark.externalExtractor as CKRecordValue?
|
||||
record["externalURLString"] = bookmark.externalURLString as CKRecordValue?
|
||||
|
||||
// Video Metadata (cached)
|
||||
record["title"] = bookmark.title as CKRecordValue
|
||||
record["authorName"] = bookmark.authorName as CKRecordValue
|
||||
record["authorID"] = bookmark.authorID as CKRecordValue
|
||||
record["duration"] = bookmark.duration as CKRecordValue
|
||||
record["thumbnailURLString"] = bookmark.thumbnailURLString as CKRecordValue?
|
||||
record["isLive"] = (bookmark.isLive ? 1 : 0) as CKRecordValue
|
||||
record["viewCount"] = bookmark.viewCount.map { Int64($0) } as CKRecordValue?
|
||||
record["publishedAt"] = bookmark.publishedAt as CKRecordValue?
|
||||
record["publishedText"] = bookmark.publishedText as CKRecordValue?
|
||||
|
||||
// Bookmark Metadata
|
||||
record["createdAt"] = bookmark.createdAt as CKRecordValue
|
||||
record["note"] = bookmark.note as CKRecordValue?
|
||||
record["noteModifiedAt"] = bookmark.noteModifiedAt as CKRecordValue?
|
||||
record["tags"] = bookmark.tags as CKRecordValue
|
||||
record["tagsModifiedAt"] = bookmark.tagsModifiedAt as CKRecordValue?
|
||||
record["sortOrder"] = Int64(bookmark.sortOrder) as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a Bookmark from a CKRecord.
|
||||
func toBookmark(from record: CKRecord) throws -> Bookmark {
|
||||
let recordType = RecordType.bookmark
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let videoID = record["videoID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "videoID", recordType: recordType)
|
||||
}
|
||||
guard let sourceRawValue = record["sourceRawValue"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
|
||||
}
|
||||
guard let title = record["title"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
|
||||
}
|
||||
guard let authorName = record["authorName"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "authorName", recordType: recordType)
|
||||
}
|
||||
guard let authorID = record["authorID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "authorID", recordType: recordType)
|
||||
}
|
||||
guard let duration = record["duration"] as? Double else {
|
||||
throw CloudKitError.missingRequiredField(field: "duration", recordType: recordType)
|
||||
}
|
||||
guard let isLiveInt = record["isLive"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "isLive", recordType: recordType)
|
||||
}
|
||||
guard let createdAt = record["createdAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "createdAt", recordType: recordType)
|
||||
}
|
||||
guard let sortOrderInt = record["sortOrder"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "sortOrder", recordType: recordType)
|
||||
}
|
||||
|
||||
// Create bookmark
|
||||
let bookmark = Bookmark(
|
||||
videoID: videoID,
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: record["globalProvider"] as? String,
|
||||
instanceURLString: record["instanceURLString"] as? String,
|
||||
peertubeUUID: record["peertubeUUID"] as? String,
|
||||
externalExtractor: record["externalExtractor"] as? String,
|
||||
externalURLString: record["externalURLString"] as? String,
|
||||
title: title,
|
||||
authorName: authorName,
|
||||
authorID: authorID,
|
||||
duration: duration,
|
||||
thumbnailURLString: record["thumbnailURLString"] as? String,
|
||||
isLive: isLiveInt == 1,
|
||||
viewCount: (record["viewCount"] as? Int64).map(Int.init),
|
||||
publishedAt: record["publishedAt"] as? Date,
|
||||
publishedText: record["publishedText"] as? String,
|
||||
note: record["note"] as? String,
|
||||
noteModifiedAt: record["noteModifiedAt"] as? Date,
|
||||
tags: record["tags"] as? [String] ?? [],
|
||||
tagsModifiedAt: record["tagsModifiedAt"] as? Date,
|
||||
sortOrder: Int(sortOrderInt)
|
||||
)
|
||||
|
||||
// Set timestamp
|
||||
bookmark.createdAt = createdAt
|
||||
|
||||
return bookmark
|
||||
}
|
||||
|
||||
// MARK: - LocalPlaylist Mapping
|
||||
|
||||
/// Converts a LocalPlaylist to a CKRecord.
|
||||
func toCKRecord(playlist: LocalPlaylist) -> CKRecord {
|
||||
let recordID = SyncableRecordType.localPlaylist(id: playlist.id).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.localPlaylist, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
// Playlist Metadata
|
||||
record["playlistID"] = playlist.id.uuidString as CKRecordValue
|
||||
record["title"] = playlist.title as CKRecordValue
|
||||
record["playlistDescription"] = playlist.playlistDescription as CKRecordValue?
|
||||
record["createdAt"] = playlist.createdAt as CKRecordValue
|
||||
record["updatedAt"] = playlist.updatedAt as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a LocalPlaylist from a CKRecord.
|
||||
func toLocalPlaylist(from record: CKRecord) throws -> LocalPlaylist {
|
||||
let recordType = RecordType.localPlaylist
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let playlistIDString = record["playlistID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "playlistID", recordType: recordType)
|
||||
}
|
||||
guard let playlistID = UUID(uuidString: playlistIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "playlistID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard let title = record["title"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
|
||||
}
|
||||
guard let createdAt = record["createdAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "createdAt", recordType: recordType)
|
||||
}
|
||||
guard let updatedAt = record["updatedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
// Create playlist
|
||||
let playlist = LocalPlaylist(
|
||||
id: playlistID,
|
||||
title: title,
|
||||
description: record["playlistDescription"] as? String
|
||||
)
|
||||
|
||||
// Set timestamps
|
||||
playlist.createdAt = createdAt
|
||||
playlist.updatedAt = updatedAt
|
||||
|
||||
return playlist
|
||||
}
|
||||
|
||||
// MARK: - LocalPlaylistItem Mapping
|
||||
|
||||
/// Converts a LocalPlaylistItem to a CKRecord.
|
||||
func toCKRecord(playlistItem: LocalPlaylistItem) -> CKRecord {
|
||||
let recordID = SyncableRecordType.localPlaylistItem(id: playlistItem.id).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.localPlaylistItem, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
// Item Identity
|
||||
record["itemID"] = playlistItem.id.uuidString as CKRecordValue
|
||||
record["playlistID"] = playlistItem.playlist?.id.uuidString as CKRecordValue?
|
||||
record["sortOrder"] = Int64(playlistItem.sortOrder) as CKRecordValue
|
||||
|
||||
// Video Identity
|
||||
record["videoID"] = playlistItem.videoID as CKRecordValue
|
||||
record["sourceRawValue"] = playlistItem.sourceRawValue as CKRecordValue
|
||||
record["globalProvider"] = playlistItem.globalProvider as CKRecordValue?
|
||||
record["instanceURLString"] = playlistItem.instanceURLString as CKRecordValue?
|
||||
record["peertubeUUID"] = playlistItem.peertubeUUID as CKRecordValue?
|
||||
record["externalExtractor"] = playlistItem.externalExtractor as CKRecordValue?
|
||||
record["externalURLString"] = playlistItem.externalURLString as CKRecordValue?
|
||||
|
||||
// Video Metadata
|
||||
record["title"] = playlistItem.title as CKRecordValue
|
||||
record["authorName"] = playlistItem.authorName as CKRecordValue
|
||||
record["authorID"] = playlistItem.authorID as CKRecordValue
|
||||
record["duration"] = playlistItem.duration as CKRecordValue
|
||||
record["thumbnailURLString"] = playlistItem.thumbnailURLString as CKRecordValue?
|
||||
record["isLive"] = (playlistItem.isLive ? 1 : 0) as CKRecordValue
|
||||
record["addedAt"] = playlistItem.addedAt as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a LocalPlaylistItem from a CKRecord.
|
||||
func toLocalPlaylistItem(from record: CKRecord) throws -> (item: LocalPlaylistItem, playlistID: UUID?) {
|
||||
let recordType = RecordType.localPlaylistItem
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let itemIDString = record["itemID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "itemID", recordType: recordType)
|
||||
}
|
||||
guard let itemID = UUID(uuidString: itemIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "itemID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard let sortOrderInt = record["sortOrder"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "sortOrder", recordType: recordType)
|
||||
}
|
||||
guard let videoID = record["videoID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "videoID", recordType: recordType)
|
||||
}
|
||||
guard let sourceRawValue = record["sourceRawValue"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
|
||||
}
|
||||
guard let title = record["title"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
|
||||
}
|
||||
guard let authorName = record["authorName"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "authorName", recordType: recordType)
|
||||
}
|
||||
guard let authorID = record["authorID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "authorID", recordType: recordType)
|
||||
}
|
||||
guard let duration = record["duration"] as? Double else {
|
||||
throw CloudKitError.missingRequiredField(field: "duration", recordType: recordType)
|
||||
}
|
||||
guard let isLiveInt = record["isLive"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "isLive", recordType: recordType)
|
||||
}
|
||||
guard let addedAt = record["addedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "addedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
// Extract playlist ID
|
||||
let playlistID: UUID?
|
||||
if let playlistIDString = record["playlistID"] as? String {
|
||||
playlistID = UUID(uuidString: playlistIDString)
|
||||
} else {
|
||||
playlistID = nil
|
||||
}
|
||||
|
||||
// Create playlist item
|
||||
let item = LocalPlaylistItem(
|
||||
id: itemID,
|
||||
sortOrder: Int(sortOrderInt),
|
||||
videoID: videoID,
|
||||
sourceRawValue: sourceRawValue,
|
||||
globalProvider: record["globalProvider"] as? String,
|
||||
instanceURLString: record["instanceURLString"] as? String,
|
||||
peertubeUUID: record["peertubeUUID"] as? String,
|
||||
externalExtractor: record["externalExtractor"] as? String,
|
||||
externalURLString: record["externalURLString"] as? String,
|
||||
title: title,
|
||||
authorName: authorName,
|
||||
authorID: authorID,
|
||||
duration: duration,
|
||||
thumbnailURLString: record["thumbnailURLString"] as? String,
|
||||
isLive: isLiveInt == 1
|
||||
)
|
||||
|
||||
// Set timestamp
|
||||
item.addedAt = addedAt
|
||||
|
||||
return (item, playlistID)
|
||||
}
|
||||
|
||||
// MARK: - SearchHistory Mapping
|
||||
|
||||
/// Converts a SearchHistory to a CKRecord.
|
||||
func toCKRecord(searchHistory: SearchHistory) -> CKRecord {
|
||||
let recordID = SyncableRecordType.searchHistory(id: searchHistory.id).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.searchHistory, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
record["searchID"] = searchHistory.id.uuidString as CKRecordValue
|
||||
record["query"] = searchHistory.query as CKRecordValue
|
||||
record["searchedAt"] = searchHistory.searchedAt as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a SearchHistory from a CKRecord.
|
||||
func toSearchHistory(from record: CKRecord) throws -> SearchHistory {
|
||||
let recordType = RecordType.searchHistory
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let searchIDString = record["searchID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "searchID", recordType: recordType)
|
||||
}
|
||||
guard let searchID = UUID(uuidString: searchIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "searchID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard let query = record["query"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "query", recordType: recordType)
|
||||
}
|
||||
guard let searchedAt = record["searchedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "searchedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
return SearchHistory(id: searchID, query: query, searchedAt: searchedAt)
|
||||
}
|
||||
|
||||
// MARK: - RecentChannel Mapping
|
||||
|
||||
/// Converts a RecentChannel to a CKRecord.
|
||||
func toCKRecord(recentChannel: RecentChannel) -> CKRecord {
|
||||
let scope = SourceScope.from(
|
||||
sourceRawValue: recentChannel.sourceRawValue,
|
||||
globalProvider: nil,
|
||||
instanceURLString: recentChannel.instanceURLString,
|
||||
externalExtractor: nil
|
||||
)
|
||||
let recordID = SyncableRecordType.recentChannel(channelID: recentChannel.channelID, scope: scope).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.recentChannel, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
record["recentID"] = recentChannel.id.uuidString as CKRecordValue
|
||||
record["channelID"] = recentChannel.channelID as CKRecordValue
|
||||
record["sourceRawValue"] = recentChannel.sourceRawValue as CKRecordValue
|
||||
record["instanceURLString"] = recentChannel.instanceURLString as CKRecordValue?
|
||||
record["name"] = recentChannel.name as CKRecordValue
|
||||
record["thumbnailURLString"] = recentChannel.thumbnailURLString as CKRecordValue?
|
||||
record["subscriberCount"] = recentChannel.subscriberCount.map { Int64($0) } as CKRecordValue?
|
||||
record["isVerified"] = (recentChannel.isVerified ? 1 : 0) as CKRecordValue
|
||||
record["visitedAt"] = recentChannel.visitedAt as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a RecentChannel from a CKRecord.
|
||||
func toRecentChannel(from record: CKRecord) throws -> RecentChannel {
|
||||
let recordType = RecordType.recentChannel
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let recentIDString = record["recentID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "recentID", recordType: recordType)
|
||||
}
|
||||
guard let recentID = UUID(uuidString: recentIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "recentID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard let channelID = record["channelID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "channelID", recordType: recordType)
|
||||
}
|
||||
guard let sourceRawValue = record["sourceRawValue"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
|
||||
}
|
||||
guard let name = record["name"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "name", recordType: recordType)
|
||||
}
|
||||
guard let isVerifiedInt = record["isVerified"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "isVerified", recordType: recordType)
|
||||
}
|
||||
guard let visitedAt = record["visitedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "visitedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
return RecentChannel(
|
||||
id: recentID,
|
||||
channelID: channelID,
|
||||
sourceRawValue: sourceRawValue,
|
||||
instanceURLString: record["instanceURLString"] as? String,
|
||||
name: name,
|
||||
thumbnailURLString: record["thumbnailURLString"] as? String,
|
||||
subscriberCount: (record["subscriberCount"] as? Int64).map(Int.init),
|
||||
isVerified: isVerifiedInt == 1,
|
||||
visitedAt: visitedAt
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - RecentPlaylist Mapping
|
||||
|
||||
/// Converts a RecentPlaylist to a CKRecord.
|
||||
func toCKRecord(recentPlaylist: RecentPlaylist) -> CKRecord {
|
||||
let scope = SourceScope.from(
|
||||
sourceRawValue: recentPlaylist.sourceRawValue,
|
||||
globalProvider: nil,
|
||||
instanceURLString: recentPlaylist.instanceURLString,
|
||||
externalExtractor: nil
|
||||
)
|
||||
let recordID = SyncableRecordType.recentPlaylist(playlistID: recentPlaylist.playlistID, scope: scope).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.recentPlaylist, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
record["recentID"] = recentPlaylist.id.uuidString as CKRecordValue
|
||||
record["playlistID"] = recentPlaylist.playlistID as CKRecordValue
|
||||
record["sourceRawValue"] = recentPlaylist.sourceRawValue as CKRecordValue
|
||||
record["instanceURLString"] = recentPlaylist.instanceURLString as CKRecordValue?
|
||||
record["title"] = recentPlaylist.title as CKRecordValue
|
||||
record["authorName"] = recentPlaylist.authorName as CKRecordValue
|
||||
record["videoCount"] = Int64(recentPlaylist.videoCount) as CKRecordValue
|
||||
record["thumbnailURLString"] = recentPlaylist.thumbnailURLString as CKRecordValue?
|
||||
record["visitedAt"] = recentPlaylist.visitedAt as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a RecentPlaylist from a CKRecord.
|
||||
func toRecentPlaylist(from record: CKRecord) throws -> RecentPlaylist {
|
||||
let recordType = RecordType.recentPlaylist
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let recentIDString = record["recentID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "recentID", recordType: recordType)
|
||||
}
|
||||
guard let recentID = UUID(uuidString: recentIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "recentID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard let playlistID = record["playlistID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "playlistID", recordType: recordType)
|
||||
}
|
||||
guard let sourceRawValue = record["sourceRawValue"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "sourceRawValue", recordType: recordType)
|
||||
}
|
||||
guard let title = record["title"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "title", recordType: recordType)
|
||||
}
|
||||
guard let authorName = record["authorName"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "authorName", recordType: recordType)
|
||||
}
|
||||
guard let videoCountInt = record["videoCount"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "videoCount", recordType: recordType)
|
||||
}
|
||||
guard let visitedAt = record["visitedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "visitedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
return RecentPlaylist(
|
||||
id: recentID,
|
||||
playlistID: playlistID,
|
||||
sourceRawValue: sourceRawValue,
|
||||
instanceURLString: record["instanceURLString"] as? String,
|
||||
title: title,
|
||||
authorName: authorName,
|
||||
videoCount: Int(videoCountInt),
|
||||
thumbnailURLString: record["thumbnailURLString"] as? String,
|
||||
visitedAt: visitedAt
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - ChannelNotificationSettings Mapping
|
||||
|
||||
/// Converts a ChannelNotificationSettings to a CKRecord.
|
||||
func toCKRecord(channelNotificationSettings settings: ChannelNotificationSettings) -> CKRecord {
|
||||
let scope = SourceScope.from(
|
||||
sourceRawValue: settings.sourceRawValue,
|
||||
globalProvider: settings.globalProvider,
|
||||
instanceURLString: settings.instanceURLString,
|
||||
externalExtractor: nil
|
||||
)
|
||||
let recordID = SyncableRecordType.channelNotificationSettings(channelID: settings.channelID, scope: scope).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.channelNotificationSettings, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
record["channelID"] = settings.channelID as CKRecordValue
|
||||
record["notificationsEnabled"] = (settings.notificationsEnabled ? 1 : 0) as CKRecordValue
|
||||
record["updatedAt"] = settings.updatedAt as CKRecordValue
|
||||
record["sourceRawValue"] = settings.sourceRawValue as CKRecordValue
|
||||
record["instanceURLString"] = settings.instanceURLString as CKRecordValue?
|
||||
record["globalProvider"] = settings.globalProvider as CKRecordValue?
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a ChannelNotificationSettings from a CKRecord.
|
||||
func toChannelNotificationSettings(from record: CKRecord) throws -> ChannelNotificationSettings {
|
||||
let recordType = RecordType.channelNotificationSettings
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let channelID = record["channelID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "channelID", recordType: recordType)
|
||||
}
|
||||
guard let notificationsEnabledInt = record["notificationsEnabled"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "notificationsEnabled", recordType: recordType)
|
||||
}
|
||||
guard let updatedAt = record["updatedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
|
||||
}
|
||||
|
||||
let settings = ChannelNotificationSettings(
|
||||
channelID: channelID,
|
||||
notificationsEnabled: notificationsEnabledInt == 1,
|
||||
sourceRawValue: (record["sourceRawValue"] as? String) ?? "global",
|
||||
instanceURLString: record["instanceURLString"] as? String,
|
||||
globalProvider: record["globalProvider"] as? String
|
||||
)
|
||||
settings.updatedAt = updatedAt
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
// MARK: - LayoutPreset Mapping
|
||||
|
||||
/// Converts a LayoutPreset to a CKRecord.
|
||||
func toCKRecord(preset: LayoutPreset) throws -> CKRecord {
|
||||
let recordID = SyncableRecordType.controlsPreset(id: preset.id).recordID(in: zone)
|
||||
let record = CKRecord(recordType: RecordType.controlsPreset, recordID: recordID)
|
||||
|
||||
// Schema version
|
||||
record["schemaVersion"] = currentSchemaVersion as CKRecordValue
|
||||
|
||||
// Preset metadata
|
||||
record["presetID"] = preset.id.uuidString as CKRecordValue
|
||||
record["name"] = preset.name as CKRecordValue
|
||||
record["createdAt"] = preset.createdAt as CKRecordValue
|
||||
record["updatedAt"] = preset.updatedAt as CKRecordValue
|
||||
record["isBuiltIn"] = (preset.isBuiltIn ? 1 : 0) as CKRecordValue
|
||||
record["deviceClass"] = preset.deviceClass.rawValue as CKRecordValue
|
||||
|
||||
// Encode layout as JSON string
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let layoutData = try encoder.encode(preset.layout)
|
||||
let layoutJSON = String(data: layoutData, encoding: .utf8) ?? "{}"
|
||||
record["layoutJSON"] = layoutJSON as CKRecordValue
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/// Creates a LayoutPreset from a CKRecord.
|
||||
func toLayoutPreset(from record: CKRecord) throws -> LayoutPreset {
|
||||
let recordType = RecordType.controlsPreset
|
||||
guard record.recordType == recordType else {
|
||||
throw CloudKitError.recordNotFound
|
||||
}
|
||||
|
||||
// Version check - treat missing version as v1 (first versioned release)
|
||||
let version = (record["schemaVersion"] as? Int64) ?? 1
|
||||
guard version <= currentSchemaVersion else {
|
||||
throw CloudKitError.unsupportedSchemaVersion(version: version, recordType: recordType)
|
||||
}
|
||||
|
||||
// Required fields with specific error messages
|
||||
guard let presetIDString = record["presetID"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "presetID", recordType: recordType)
|
||||
}
|
||||
guard let presetID = UUID(uuidString: presetIDString) else {
|
||||
throw CloudKitError.typeMismatch(field: "presetID", recordType: recordType, expected: "valid UUID string")
|
||||
}
|
||||
guard let name = record["name"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "name", recordType: recordType)
|
||||
}
|
||||
guard let createdAt = record["createdAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "createdAt", recordType: recordType)
|
||||
}
|
||||
guard let updatedAt = record["updatedAt"] as? Date else {
|
||||
throw CloudKitError.missingRequiredField(field: "updatedAt", recordType: recordType)
|
||||
}
|
||||
guard let isBuiltInInt = record["isBuiltIn"] as? Int64 else {
|
||||
throw CloudKitError.missingRequiredField(field: "isBuiltIn", recordType: recordType)
|
||||
}
|
||||
guard let deviceClassRaw = record["deviceClass"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "deviceClass", recordType: recordType)
|
||||
}
|
||||
guard let deviceClass = DeviceClass(rawValue: deviceClassRaw) else {
|
||||
throw CloudKitError.typeMismatch(field: "deviceClass", recordType: recordType, expected: "valid DeviceClass value")
|
||||
}
|
||||
guard let layoutJSON = record["layoutJSON"] as? String else {
|
||||
throw CloudKitError.missingRequiredField(field: "layoutJSON", recordType: recordType)
|
||||
}
|
||||
guard let layoutData = layoutJSON.data(using: .utf8) else {
|
||||
throw CloudKitError.typeMismatch(field: "layoutJSON", recordType: recordType, expected: "valid UTF-8 JSON string")
|
||||
}
|
||||
|
||||
// Decode layout
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let layout = try decoder.decode(PlayerControlsLayout.self, from: layoutData)
|
||||
|
||||
return LayoutPreset(
|
||||
id: presetID,
|
||||
name: name,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
isBuiltIn: isBuiltInInt == 1,
|
||||
deviceClass: deviceClass,
|
||||
layout: layout
|
||||
)
|
||||
}
|
||||
}
|
||||
2538
Yattee/Services/CloudKit/CloudKitSyncEngine.swift
Normal file
2538
Yattee/Services/CloudKit/CloudKitSyncEngine.swift
Normal file
File diff suppressed because it is too large
Load Diff
90
Yattee/Services/CloudKit/CloudKitZoneManager.swift
Normal file
90
Yattee/Services/CloudKit/CloudKitZoneManager.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// CloudKitZoneManager.swift
|
||||
// Yattee
|
||||
//
|
||||
// Manages CloudKit zone creation and configuration.
|
||||
//
|
||||
|
||||
import CloudKit
|
||||
import Foundation
|
||||
|
||||
/// Manages the CloudKit record zone for user data.
|
||||
actor CloudKitZoneManager {
|
||||
// MARK: - Properties
|
||||
|
||||
private let database: CKDatabase
|
||||
private let zone: CKRecordZone
|
||||
private var isZoneCreated = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(database: CKDatabase) {
|
||||
self.database = database
|
||||
self.zone = RecordType.createZone()
|
||||
}
|
||||
|
||||
// MARK: - Zone Management
|
||||
|
||||
/// Returns the configured zone.
|
||||
func getZone() -> CKRecordZone {
|
||||
zone
|
||||
}
|
||||
|
||||
/// Creates the custom zone if it doesn't exist.
|
||||
/// Safe to call multiple times - CloudKit handles idempotency.
|
||||
func createZoneIfNeeded() async throws {
|
||||
guard !isZoneCreated else { return }
|
||||
|
||||
do {
|
||||
let result = try await database.modifyRecordZones(
|
||||
saving: [zone],
|
||||
deleting: []
|
||||
)
|
||||
|
||||
if !result.saveResults.isEmpty {
|
||||
isZoneCreated = true
|
||||
await MainActor.run {
|
||||
LoggingService.shared.logCloudKit("CloudKit zone '\(RecordType.zoneName)' created/verified")
|
||||
}
|
||||
}
|
||||
} catch let error as CKError where error.code == .zoneNotFound {
|
||||
// Zone doesn't exist, try creating again
|
||||
await MainActor.run {
|
||||
LoggingService.shared.logCloudKit("Zone not found, retrying creation")
|
||||
}
|
||||
throw error
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
LoggingService.shared.logCloudKitError("Failed to create zone", error: error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the zone and all its records. Use for testing/reset only.
|
||||
func deleteZone() async throws {
|
||||
do {
|
||||
let result = try await database.modifyRecordZones(
|
||||
saving: [],
|
||||
deleting: [zone.zoneID]
|
||||
)
|
||||
|
||||
if !result.deleteResults.isEmpty {
|
||||
isZoneCreated = false
|
||||
await MainActor.run {
|
||||
LoggingService.shared.logCloudKit("CloudKit zone deleted")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
LoggingService.shared.logCloudKitError("Failed to delete zone", error: error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches all zones in the private database.
|
||||
func fetchAllZones() async throws -> [CKRecordZone] {
|
||||
try await database.allRecordZones()
|
||||
}
|
||||
}
|
||||
166
Yattee/Services/CloudKit/RecordTypes.swift
Normal file
166
Yattee/Services/CloudKit/RecordTypes.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// RecordTypes.swift
|
||||
// Yattee
|
||||
//
|
||||
// CloudKit record type constants and zone configuration.
|
||||
//
|
||||
|
||||
import CloudKit
|
||||
import Foundation
|
||||
|
||||
/// CloudKit record type names and constants.
|
||||
enum RecordType {
|
||||
static let subscription = "Subscription"
|
||||
static let watchEntry = "WatchEntry"
|
||||
static let bookmark = "Bookmark"
|
||||
static let localPlaylist = "LocalPlaylist"
|
||||
static let localPlaylistItem = "LocalPlaylistItem"
|
||||
static let searchHistory = "SearchHistory"
|
||||
static let recentChannel = "RecentChannel"
|
||||
static let recentPlaylist = "RecentPlaylist"
|
||||
static let appSettings = "AppSettings"
|
||||
static let instance = "Instance"
|
||||
static let mediaSource = "MediaSource"
|
||||
static let channelNotificationSettings = "ChannelNotificationSettings"
|
||||
static let controlsPreset = "ControlsPreset"
|
||||
|
||||
/// CloudKit zone name for all user data.
|
||||
static let zoneName = "UserData"
|
||||
|
||||
/// CloudKit zone for all synced records.
|
||||
static func createZone() -> CKRecordZone {
|
||||
CKRecordZone(zoneName: zoneName)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates source-scoped suffixes for CloudKit record names to prevent cross-source collisions.
|
||||
struct SourceScope: Sendable {
|
||||
let sourceRawValue: String
|
||||
let provider: String?
|
||||
let instanceHost: String?
|
||||
let extractor: String?
|
||||
|
||||
var recordNameSuffix: String {
|
||||
switch sourceRawValue {
|
||||
case "federated": "@federated:\(instanceHost ?? "unknown")"
|
||||
case "extracted": "@extracted:\(extractor ?? "unknown")"
|
||||
default: "@global:\(provider ?? "youtube")"
|
||||
}
|
||||
}
|
||||
|
||||
static func from(
|
||||
sourceRawValue: String,
|
||||
globalProvider: String?,
|
||||
instanceURLString: String?,
|
||||
externalExtractor: String?
|
||||
) -> SourceScope {
|
||||
let host: String? = instanceURLString.flatMap { URL(string: $0)?.host }
|
||||
return SourceScope(
|
||||
sourceRawValue: sourceRawValue,
|
||||
provider: globalProvider,
|
||||
instanceHost: host,
|
||||
extractor: externalExtractor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a syncable record type with its identifier strategy.
|
||||
enum SyncableRecordType: Sendable {
|
||||
case subscription(channelID: String, scope: SourceScope)
|
||||
case watchEntry(videoID: String, scope: SourceScope)
|
||||
case bookmark(videoID: String, scope: SourceScope)
|
||||
case localPlaylist(id: UUID)
|
||||
case localPlaylistItem(id: UUID)
|
||||
case searchHistory(id: UUID)
|
||||
case recentChannel(channelID: String, scope: SourceScope)
|
||||
case recentPlaylist(playlistID: String, scope: SourceScope)
|
||||
case appSettings
|
||||
case instance(id: UUID)
|
||||
case mediaSource(id: UUID)
|
||||
case channelNotificationSettings(channelID: String, scope: SourceScope)
|
||||
case controlsPreset(id: UUID)
|
||||
|
||||
/// The CloudKit record type name.
|
||||
var recordTypeName: String {
|
||||
switch self {
|
||||
case .subscription: RecordType.subscription
|
||||
case .watchEntry: RecordType.watchEntry
|
||||
case .bookmark: RecordType.bookmark
|
||||
case .localPlaylist: RecordType.localPlaylist
|
||||
case .localPlaylistItem: RecordType.localPlaylistItem
|
||||
case .searchHistory: RecordType.searchHistory
|
||||
case .recentChannel: RecordType.recentChannel
|
||||
case .recentPlaylist: RecordType.recentPlaylist
|
||||
case .appSettings: RecordType.appSettings
|
||||
case .instance: RecordType.instance
|
||||
case .mediaSource: RecordType.mediaSource
|
||||
case .channelNotificationSettings: RecordType.channelNotificationSettings
|
||||
case .controlsPreset: RecordType.controlsPreset
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the bare ID from a record name by stripping the scope suffix.
|
||||
static func extractBareID(from value: String) -> String {
|
||||
for prefix in ["@global:", "@federated:", "@extracted:"] {
|
||||
if let range = value.range(of: prefix) {
|
||||
return String(value[value.startIndex..<range.lowerBound])
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// The CloudKit record ID for this entity.
|
||||
func recordID(in zone: CKRecordZone) -> CKRecord.ID {
|
||||
let recordName: String
|
||||
|
||||
switch self {
|
||||
case .subscription(let channelID, let scope):
|
||||
recordName = "sub-\(channelID)\(scope.recordNameSuffix)"
|
||||
case .watchEntry(let videoID, let scope):
|
||||
recordName = "watch-\(videoID)\(scope.recordNameSuffix)"
|
||||
case .bookmark(let videoID, let scope):
|
||||
recordName = "bookmark-\(videoID)\(scope.recordNameSuffix)"
|
||||
case .localPlaylist(let id):
|
||||
recordName = "playlist-\(id.uuidString)"
|
||||
case .localPlaylistItem(let id):
|
||||
recordName = "item-\(id.uuidString)"
|
||||
case .searchHistory(let id):
|
||||
recordName = "search-\(id.uuidString)"
|
||||
case .recentChannel(let channelID, let scope):
|
||||
recordName = "recent-channel-\(channelID)\(scope.recordNameSuffix)"
|
||||
case .recentPlaylist(let playlistID, let scope):
|
||||
recordName = "recent-playlist-\(playlistID)\(scope.recordNameSuffix)"
|
||||
case .appSettings:
|
||||
recordName = "settings-singleton"
|
||||
case .instance(let id):
|
||||
recordName = "instance-\(id.uuidString)"
|
||||
case .mediaSource(let id):
|
||||
recordName = "source-\(id.uuidString)"
|
||||
case .channelNotificationSettings(let channelID, let scope):
|
||||
recordName = "channel-notif-\(channelID)\(scope.recordNameSuffix)"
|
||||
case .controlsPreset(let id):
|
||||
recordName = "controls-\(id.uuidString)"
|
||||
}
|
||||
|
||||
return CKRecord.ID(recordName: recordName, zoneID: zone.zoneID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync operation type.
|
||||
enum SyncOperation: Sendable {
|
||||
case save
|
||||
case delete
|
||||
}
|
||||
|
||||
/// Pending sync change to be uploaded to CloudKit.
|
||||
struct PendingSyncChange: Sendable {
|
||||
let recordType: SyncableRecordType
|
||||
let operation: SyncOperation
|
||||
let timestamp: Date
|
||||
|
||||
init(recordType: SyncableRecordType, operation: SyncOperation) {
|
||||
self.recordType = recordType
|
||||
self.operation = operation
|
||||
self.timestamp = Date()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user