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:
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user