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:
517
Yattee/Core/MediaSourcesManager.swift
Normal file
517
Yattee/Core/MediaSourcesManager.swift
Normal file
@@ -0,0 +1,517 @@
|
||||
//
|
||||
// MediaSourcesManager.swift
|
||||
// Yattee
|
||||
//
|
||||
// Manages configured media sources with persistence.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Manages the list of configured media sources.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MediaSourcesManager {
|
||||
// MARK: - Storage
|
||||
|
||||
private let localDefaults = UserDefaults.standard
|
||||
private let ubiquitousStore = NSUbiquitousKeyValueStore.default
|
||||
private let sourcesKey = "configuredMediaSources"
|
||||
private let iCloudSourcesKey = "syncedMediaSources"
|
||||
private let keychainServiceName = "com.yattee.mediasources"
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private weak var settingsManager: SettingsManager?
|
||||
private weak var dataManager: DataManager?
|
||||
|
||||
// MARK: - Sync State
|
||||
|
||||
private var isImportingFromiCloud = false
|
||||
private var iCloudObserver: NSObjectProtocol?
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private(set) var sources: [MediaSource] = []
|
||||
|
||||
/// Tracks which sources have passwords stored (for reactive UI updates)
|
||||
private(set) var passwordStoredSourceIDs: Set<UUID> = []
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(settingsManager: SettingsManager? = nil) {
|
||||
self.settingsManager = settingsManager
|
||||
loadSources()
|
||||
observeiCloudChanges()
|
||||
// Import or refresh network sources from iCloud on startup
|
||||
importFromiCloudOnStartupIfNeeded()
|
||||
}
|
||||
|
||||
/// Imports or refreshes network sources (WebDAV and SMB) from iCloud on startup.
|
||||
/// - If no local network sources exist: imports all from iCloud (first-time setup).
|
||||
/// - If local network sources differ from iCloud: replaces local with iCloud data
|
||||
/// (catches name changes, enable/disable toggles, etc. made on other devices while app was closed).
|
||||
private func importFromiCloudOnStartupIfNeeded() {
|
||||
guard iCloudSyncEnabled else {
|
||||
LoggingService.shared.debug("MediaSources startup: iCloud sync disabled, skipping import", category: .cloudKit)
|
||||
return
|
||||
}
|
||||
|
||||
ubiquitousStore.synchronize()
|
||||
|
||||
guard let data = ubiquitousStore.data(forKey: iCloudSourcesKey),
|
||||
let exports = try? JSONDecoder().decode([MediaSourceExport].self, from: data),
|
||||
!exports.isEmpty else {
|
||||
LoggingService.shared.debug("MediaSources startup: No network sources in iCloud", category: .cloudKit)
|
||||
return
|
||||
}
|
||||
|
||||
let iCloudNetworkSources = exports.compactMap { $0.toMediaSource() }
|
||||
|
||||
if networkSources.isEmpty {
|
||||
// First-time import: no local network sources
|
||||
LoggingService.shared.info("MediaSources startup: Importing \(iCloudNetworkSources.count) network sources from iCloud", category: .cloudKit)
|
||||
sources.append(contentsOf: iCloudNetworkSources)
|
||||
saveSources()
|
||||
refreshPasswordStoredStatus()
|
||||
} else if networkSources != iCloudNetworkSources {
|
||||
// Existing sources differ from iCloud - refresh from iCloud
|
||||
// This catches name changes, enable/disable toggles, etc. made on other devices
|
||||
LoggingService.shared.info("MediaSources startup: Refreshing \(iCloudNetworkSources.count) network sources from iCloud (local differs)", category: .cloudKit)
|
||||
isImportingFromiCloud = true
|
||||
defer { isImportingFromiCloud = false }
|
||||
let localFolderSources = sources.filter { $0.type == .localFolder }
|
||||
sources = localFolderSources + iCloudNetworkSources
|
||||
saveSources()
|
||||
refreshPasswordStoredStatus()
|
||||
} else {
|
||||
LoggingService.shared.debug("MediaSources startup: Local sources match iCloud, no update needed", category: .cloudKit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the settings manager reference (for dependency injection after init).
|
||||
func configure(settingsManager: SettingsManager) {
|
||||
self.settingsManager = settingsManager
|
||||
}
|
||||
|
||||
/// Sets the data manager reference (for cleanup when sources are deleted).
|
||||
func setDataManager(_ manager: DataManager) {
|
||||
self.dataManager = manager
|
||||
}
|
||||
|
||||
// MARK: - Source Management
|
||||
|
||||
/// Adds a new media source.
|
||||
func add(_ source: MediaSource) {
|
||||
sources.append(source)
|
||||
saveSources()
|
||||
syncToiCloudIfNeeded()
|
||||
}
|
||||
|
||||
/// Removes a media source and its stored credentials.
|
||||
func remove(_ source: MediaSource) {
|
||||
// Clean up associated data (history, bookmarks, playlist items)
|
||||
dataManager?.removeAllDataForMediaSource(sourceID: source.id)
|
||||
|
||||
// Remove from Home cards/sections
|
||||
settingsManager?.removeFromHome(sourceID: source.id)
|
||||
|
||||
sources.removeAll { $0.id == source.id }
|
||||
saveSources()
|
||||
syncToiCloudIfNeeded()
|
||||
|
||||
// Remove password from Keychain (for network sources)
|
||||
if source.type == .webdav || source.type == .smb {
|
||||
deletePassword(for: source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates an existing media source.
|
||||
func update(_ source: MediaSource) {
|
||||
if let index = sources.firstIndex(where: { $0.id == source.id }) {
|
||||
sources[index] = source
|
||||
saveSources()
|
||||
syncToiCloudIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles the enabled state of a source.
|
||||
func toggleEnabled(_ source: MediaSource) {
|
||||
if let index = sources.firstIndex(where: { $0.id == source.id }) {
|
||||
var updated = sources[index]
|
||||
updated.isEnabled.toggle()
|
||||
sources[index] = updated
|
||||
saveSources()
|
||||
syncToiCloudIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var enabledSources: [MediaSource] {
|
||||
sources.filter(\.isEnabled)
|
||||
}
|
||||
|
||||
var webdavSources: [MediaSource] {
|
||||
sources.filter { $0.type == .webdav }
|
||||
}
|
||||
|
||||
var smbSources: [MediaSource] {
|
||||
sources.filter { $0.type == .smb }
|
||||
}
|
||||
|
||||
/// All network sources (WebDAV and SMB) that can be synced to iCloud.
|
||||
var networkSources: [MediaSource] {
|
||||
sources.filter { $0.type == .webdav || $0.type == .smb }
|
||||
}
|
||||
|
||||
var localFolderSources: [MediaSource] {
|
||||
sources.filter { $0.type == .localFolder }
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
sources.isEmpty
|
||||
}
|
||||
|
||||
/// Returns true if this network source (WebDAV or SMB) needs password to be configured.
|
||||
/// Uses the tracked set for reactive UI updates.
|
||||
func needsPassword(for source: MediaSource) -> Bool {
|
||||
guard source.type == .webdav || source.type == .smb else { return false }
|
||||
return !passwordStoredSourceIDs.contains(source.id)
|
||||
}
|
||||
|
||||
/// Returns true if any network source needs password.
|
||||
var hasSourcesNeedingPassword: Bool {
|
||||
networkSources.contains { needsPassword(for: $0) }
|
||||
}
|
||||
|
||||
/// Find source by UUID.
|
||||
func source(byID id: UUID) -> MediaSource? {
|
||||
sources.first { $0.id == id }
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func loadSources() {
|
||||
if let data = localDefaults.data(forKey: sourcesKey),
|
||||
let decoded = try? JSONDecoder().decode([MediaSource].self, from: data) {
|
||||
sources = decoded
|
||||
refreshPasswordStoredStatus()
|
||||
cleanupOrphanedHomeItems()
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes Home items for sources that no longer exist
|
||||
private func cleanupOrphanedHomeItems() {
|
||||
let validSourceIDs = Set(sources.map(\.id))
|
||||
settingsManager?.cleanupOrphanedHomeMediaSourceItems(validSourceIDs: validSourceIDs)
|
||||
}
|
||||
|
||||
/// Refreshes the set of source IDs that have passwords stored (for network sources).
|
||||
/// Call this when app returns from background to sync with Keychain state.
|
||||
func refreshPasswordStoredStatus() {
|
||||
let previousIDs = passwordStoredSourceIDs
|
||||
passwordStoredSourceIDs = Set(
|
||||
sources.filter { $0.type == .webdav || $0.type == .smb }
|
||||
.filter { password(for: $0) != nil }
|
||||
.map(\.id)
|
||||
)
|
||||
|
||||
// Log if status changed (helps debug auth issues)
|
||||
if previousIDs != passwordStoredSourceIDs {
|
||||
let added = passwordStoredSourceIDs.subtracting(previousIDs)
|
||||
let removed = previousIDs.subtracting(passwordStoredSourceIDs)
|
||||
LoggingService.shared.info(
|
||||
"Password status changed",
|
||||
category: .keychain,
|
||||
details: "added=\(added.count), removed=\(removed.count)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSources() {
|
||||
guard let data = try? JSONEncoder().encode(sources) else { return }
|
||||
localDefaults.set(data, forKey: sourcesKey)
|
||||
}
|
||||
|
||||
// MARK: - Keychain (Passwords)
|
||||
|
||||
/// Stores a password for a WebDAV/SMB source in the Keychain.
|
||||
/// Password syncs to iCloud Keychain when iCloud sync is enabled for media sources.
|
||||
func setPassword(_ password: String, for source: MediaSource) {
|
||||
let account = source.id.uuidString
|
||||
guard let data = password.data(using: .utf8) else {
|
||||
LoggingService.shared.error("Failed to encode password data", category: .keychain)
|
||||
return
|
||||
}
|
||||
|
||||
let syncEnabled = shouldSyncCredentialsToiCloud
|
||||
|
||||
// First, delete any existing item (both synced and non-synced) to avoid duplicates
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainServiceName,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
// Create new item with current sync preference
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainServiceName,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: syncEnabled,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
|
||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
|
||||
if status == errSecSuccess {
|
||||
LoggingService.shared.info(
|
||||
"Stored password for \(source.name)",
|
||||
category: .keychain,
|
||||
details: "iCloudSync=\(syncEnabled)"
|
||||
)
|
||||
// Update tracked set for reactive UI
|
||||
passwordStoredSourceIDs.insert(source.id)
|
||||
} else {
|
||||
LoggingService.shared.error(
|
||||
"Failed to store password for \(source.name)",
|
||||
category: .keychain,
|
||||
details: "status=\(status)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the password for a WebDAV/SMB source from the Keychain.
|
||||
/// Searches both synced and non-synced items.
|
||||
func password(for source: MediaSource) -> String? {
|
||||
let account = source.id.uuidString
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainServiceName,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let password = String(data: data, encoding: .utf8) else {
|
||||
LoggingService.shared.debug(
|
||||
"No password found for \(source.name)",
|
||||
category: .keychain,
|
||||
details: "status=\(status)"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
LoggingService.shared.debug("Retrieved password for \(source.name)", category: .keychain)
|
||||
return password
|
||||
}
|
||||
|
||||
/// Deletes the password for a source from the Keychain.
|
||||
/// Deletes both synced and non-synced items.
|
||||
func deletePassword(for source: MediaSource) {
|
||||
let account = source.id.uuidString
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainServiceName,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
LoggingService.shared.info("Deleted password for \(source.name)", category: .keychain)
|
||||
} else {
|
||||
LoggingService.shared.error(
|
||||
"Failed to delete password for \(source.name)",
|
||||
category: .keychain,
|
||||
details: "status=\(status)"
|
||||
)
|
||||
}
|
||||
|
||||
// Update tracked set for reactive UI
|
||||
passwordStoredSourceIDs.remove(source.id)
|
||||
}
|
||||
|
||||
// MARK: - Bookmarks (Local Folders)
|
||||
|
||||
/// Updates the bookmark data for a local folder source.
|
||||
func updateBookmark(_ bookmarkData: Data, for source: MediaSource) {
|
||||
if let index = sources.firstIndex(where: { $0.id == source.id }) {
|
||||
var updated = sources[index]
|
||||
updated.bookmarkData = bookmarkData
|
||||
sources[index] = updated
|
||||
saveSources()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves and accesses a local folder source.
|
||||
/// - Parameter source: The local folder source.
|
||||
/// - Returns: The resolved URL, or nil if bookmark resolution failed.
|
||||
func resolveLocalFolderURL(for source: MediaSource) -> URL? {
|
||||
guard source.type == .localFolder,
|
||||
let bookmarkData = source.bookmarkData else {
|
||||
return source.url
|
||||
}
|
||||
|
||||
var isStale = false
|
||||
|
||||
#if os(macOS)
|
||||
let options: URL.BookmarkResolutionOptions = [.withSecurityScope]
|
||||
#else
|
||||
let options: URL.BookmarkResolutionOptions = []
|
||||
#endif
|
||||
|
||||
do {
|
||||
let url = try URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: options,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
|
||||
// If bookmark is stale, we should re-create it
|
||||
// but we can't do that without user interaction
|
||||
return url
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iCloud Sync
|
||||
|
||||
/// Whether iCloud sync is enabled and media sources sync is enabled.
|
||||
private var iCloudSyncEnabled: Bool {
|
||||
settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncMediaSources == true
|
||||
}
|
||||
|
||||
/// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for media sources).
|
||||
private var shouldSyncCredentialsToiCloud: Bool {
|
||||
iCloudSyncEnabled
|
||||
}
|
||||
|
||||
/// Syncs network sources to iCloud if sync is enabled and not currently importing.
|
||||
private func syncToiCloudIfNeeded() {
|
||||
guard iCloudSyncEnabled, !isImportingFromiCloud else { return }
|
||||
syncToiCloud()
|
||||
}
|
||||
|
||||
/// Syncs all network sources (WebDAV and SMB) to iCloud.
|
||||
/// Note: Local folder sources are never synced as they are device-specific.
|
||||
func syncToiCloud() {
|
||||
guard iCloudSyncEnabled else {
|
||||
LoggingService.shared.debug("MediaSources: iCloud sync disabled, skipping", category: .cloudKit)
|
||||
return
|
||||
}
|
||||
|
||||
let networkSources = sources.filter { $0.type == .webdav || $0.type == .smb }
|
||||
let exports = networkSources.map { MediaSourceExport(from: $0) }
|
||||
|
||||
let sourceNames = exports.map { "\($0.id): \($0.name)" }.joined(separator: ", ")
|
||||
LoggingService.shared.info("MediaSources: Syncing \(exports.count) network sources to iCloud", category: .cloudKit, details: sourceNames)
|
||||
|
||||
if let data = try? JSONEncoder().encode(exports) {
|
||||
ubiquitousStore.set(data, forKey: iCloudSourcesKey)
|
||||
ubiquitousStore.synchronize()
|
||||
LoggingService.shared.debug("MediaSources: Synced to iCloud successfully", category: .cloudKit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces local network sources (WebDAV and SMB) with iCloud data.
|
||||
/// Preserves local folder sources which are device-specific.
|
||||
func replaceWithiCloudData() {
|
||||
guard let data = ubiquitousStore.data(forKey: iCloudSourcesKey),
|
||||
let exports = try? JSONDecoder().decode([MediaSourceExport].self, from: data) else {
|
||||
LoggingService.shared.debug("MediaSources: replaceWithiCloudData - No data in iCloud or decode failed", category: .cloudKit)
|
||||
return
|
||||
}
|
||||
|
||||
isImportingFromiCloud = true
|
||||
defer { isImportingFromiCloud = false }
|
||||
|
||||
let sourceNames = exports.map { "\($0.id): \($0.name)" }.joined(separator: ", ")
|
||||
LoggingService.shared.info("MediaSources: Replacing with \(exports.count) network sources from iCloud", category: .cloudKit, details: sourceNames)
|
||||
|
||||
// Keep local folder sources
|
||||
let localFolderSources = sources.filter { $0.type == .localFolder }
|
||||
|
||||
// Convert exports to sources (WebDAV and SMB)
|
||||
let iCloudNetworkSources = exports.compactMap { $0.toMediaSource() }
|
||||
|
||||
// Merge: local folders + iCloud network sources
|
||||
sources = localFolderSources + iCloudNetworkSources
|
||||
saveSources()
|
||||
|
||||
// Refresh password status for UI reactivity
|
||||
refreshPasswordStoredStatus()
|
||||
|
||||
LoggingService.shared.info("MediaSources: Now have \(sources.count) sources (\(networkSources.count) network, \(localFolderSources.count) local)", category: .cloudKit)
|
||||
}
|
||||
|
||||
/// Observes iCloud key-value store changes.
|
||||
private func observeiCloudChanges() {
|
||||
iCloudObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: ubiquitousStore,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let self else { return }
|
||||
|
||||
// Log the change reason
|
||||
let changeReason = notification.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||
let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] ?? []
|
||||
Task { @MainActor in
|
||||
LoggingService.shared.debug("MediaSources: iCloud external change - reason=\(changeReason ?? -1), keys=\(changedKeys)", category: .cloudKit)
|
||||
}
|
||||
|
||||
// Check if our key was changed
|
||||
guard changedKeys.contains(self.iCloudSourcesKey) else {
|
||||
Task { @MainActor in
|
||||
LoggingService.shared.debug("MediaSources: iCloud change not for media sources key, ignoring", category: .cloudKit)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
// Check sync settings
|
||||
guard self.iCloudSyncEnabled else {
|
||||
LoggingService.shared.debug("MediaSources: iCloud sync disabled, ignoring external change", category: .cloudKit)
|
||||
return
|
||||
}
|
||||
|
||||
LoggingService.shared.info("MediaSources: Processing iCloud external change", category: .cloudKit)
|
||||
self.replaceWithiCloudData()
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize to get latest values
|
||||
ubiquitousStore.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Support
|
||||
|
||||
extension MediaSourcesManager {
|
||||
/// Preview manager with sample data.
|
||||
static var preview: MediaSourcesManager {
|
||||
let manager = MediaSourcesManager()
|
||||
manager.sources = [
|
||||
.webdav(name: "My NAS", url: URL(string: "https://nas.local:5006")!, username: "user"),
|
||||
.localFolder(name: "Downloads", url: URL(fileURLWithPath: "/Users/user/Downloads"))
|
||||
]
|
||||
return manager
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user