Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View 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
}
}

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

File diff suppressed because it is too large Load Diff

View 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()
}
}

View 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()
}
}