Files
yattee/Yattee/Core/MediaSourcesManager.swift
2026-02-08 18:33:56 +01:00

518 lines
19 KiB
Swift

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