Files
yattee/Yattee/Services/CloudKit/CloudKitRecordMapper.swift
2026-02-08 18:33:56 +01:00

884 lines
41 KiB
Swift

//
// 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
)
}
}