mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
On tvOS SwiftData keeps the store in the app group's Library/Caches, which the system may purge. The CKSyncEngine tokens live in UserDefaults and survive, so the next launch opened an empty store and fetched only records changed since the old tokens; subscriptions and bookmarks that had not changed since never came back. DataManager now records whether the store file existed when the container opened, and the sync engine clears its persisted state once per launch when it did not, so the first sync after a loss is a full fetch. Also log the store path and a persistent-store failure that previously fell back to in-memory storage silently.
167 lines
6.0 KiB
Swift
167 lines
6.0 KiB
Swift
//
|
|
// DataManager.swift
|
|
// Yattee
|
|
//
|
|
// Central manager for all local data operations using SwiftData.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
/// Manages all local data persistence using SwiftData with CloudKit sync.
|
|
@MainActor
|
|
@Observable
|
|
final class DataManager {
|
|
// MARK: - Properties
|
|
|
|
let modelContainer: ModelContainer
|
|
let modelContext: ModelContext
|
|
|
|
/// Weak reference to settings manager for accessing search history limit.
|
|
weak var settingsManager: SettingsManager?
|
|
|
|
/// Weak reference to CloudKit sync engine.
|
|
weak var cloudKitSync: CloudKitSyncEngine?
|
|
|
|
/// Whether CloudKit sync is currently enabled for this instance.
|
|
private(set) var isCloudKitEnabled: Bool = false
|
|
|
|
/// Whether the on-disk store did not exist when this container was opened.
|
|
///
|
|
/// On tvOS SwiftData keeps the store in Library/Caches, which the system
|
|
/// purges under storage pressure. The CloudKit sync tokens live in
|
|
/// UserDefaults and survive such a purge, so a fresh store must be reported
|
|
/// to the sync engine — otherwise it only fetches changes made since the
|
|
/// old tokens and records that did not change since (subscriptions,
|
|
/// bookmarks) never come back.
|
|
private(set) var storeWasCreated: Bool = false
|
|
|
|
/// Cached set of bookmarked video IDs for fast O(1) lookup.
|
|
/// Updated when bookmarks are added/removed locally or via CloudKit sync.
|
|
var cachedBookmarkedVideoIDs: Set<String> = []
|
|
|
|
/// Shared schema for all data models.
|
|
static let schema = Schema([
|
|
WatchEntry.self,
|
|
Bookmark.self,
|
|
LocalPlaylist.self,
|
|
LocalPlaylistItem.self,
|
|
Subscription.self,
|
|
SearchHistory.self,
|
|
RecentChannel.self,
|
|
RecentPlaylist.self,
|
|
ChannelNotificationSettings.self
|
|
])
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(inMemory: Bool = false, iCloudSyncEnabled _: Bool = false) throws {
|
|
let configuration: ModelConfiguration
|
|
|
|
// IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync.
|
|
// SwiftData CloudKit sync uses internal UUIDs which causes duplicates when
|
|
// the same data is created on multiple devices (each device generates different IDs).
|
|
//
|
|
// Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync.
|
|
// This approach:
|
|
// 1. Uses business identifiers (channelID, videoID) for deduplication
|
|
// 2. Gives explicit control over sync timing and conflict resolution
|
|
// 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync
|
|
if inMemory {
|
|
configuration = ModelConfiguration(
|
|
schema: Self.schema,
|
|
isStoredInMemoryOnly: true,
|
|
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
|
|
)
|
|
} else {
|
|
configuration = ModelConfiguration(
|
|
schema: Self.schema,
|
|
isStoredInMemoryOnly: false,
|
|
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
|
|
)
|
|
}
|
|
|
|
let storeExisted = inMemory || FileManager.default.fileExists(atPath: configuration.url.path)
|
|
self.modelContainer = try ModelContainer(
|
|
for: Self.schema,
|
|
configurations: [configuration]
|
|
)
|
|
self.storeWasCreated = !storeExisted
|
|
self.modelContext = modelContainer.mainContext
|
|
self.modelContext.autosaveEnabled = true
|
|
self.isCloudKitEnabled = false
|
|
|
|
LoggingService.shared.logCloudKit(
|
|
"SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)",
|
|
details: inMemory ? "in-memory" : "store: \(configuration.url.path), existed: \(storeExisted)"
|
|
)
|
|
|
|
// Initialize bookmark cache
|
|
refreshBookmarkCache()
|
|
|
|
// Listen for bookmark changes from CloudKit sync to refresh cache
|
|
NotificationCenter.default.addObserver(
|
|
forName: .bookmarksDidChange,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
self?.refreshBookmarkCache()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Refreshes the cached set of bookmarked video IDs.
|
|
/// Called at init and when bookmarks change via CloudKit sync.
|
|
private func refreshBookmarkCache() {
|
|
let descriptor = FetchDescriptor<Bookmark>()
|
|
do {
|
|
let bookmarks = try modelContext.fetch(descriptor)
|
|
cachedBookmarkedVideoIDs = Set(bookmarks.map { $0.videoID })
|
|
} catch {
|
|
cachedBookmarkedVideoIDs = []
|
|
LoggingService.shared.logCloudKitError("Failed to refresh bookmark cache", error: error)
|
|
}
|
|
}
|
|
|
|
/// Creates an in-memory DataManager for previews and testing.
|
|
static func preview() throws -> DataManager {
|
|
try DataManager(inMemory: true)
|
|
}
|
|
|
|
// MARK: - Utilities
|
|
|
|
/// Forces a save of any pending changes.
|
|
func save() {
|
|
do {
|
|
try modelContext.save()
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to save data", error: error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Subscription Updates
|
|
|
|
/// Updates subscriber count and verified status for a subscription by channel ID.
|
|
/// Used to populate cached metadata from Yattee Server.
|
|
func updateSubscriberCount(for channelID: String, count: Int, isVerified: Bool?) {
|
|
let descriptor = FetchDescriptor<Subscription>(
|
|
predicate: #Predicate { $0.channelID == channelID }
|
|
)
|
|
|
|
do {
|
|
if let subscription = try modelContext.fetch(descriptor).first {
|
|
subscription.subscriberCount = count
|
|
if let verified = isVerified {
|
|
subscription.isVerified = verified
|
|
}
|
|
subscription.lastUpdatedAt = Date()
|
|
save()
|
|
}
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to update subscriber count for \(channelID)", error: error)
|
|
}
|
|
}
|
|
}
|