import Foundation import Combine /// Manages background syncing of the entire library to local cache. /// The UI reads exclusively from LibraryCache — this engine fills it. /// /// Flow: /// 1. App launch → syncIfNeeded() /// 2. If lastSyncTimestamp is nil → full bootstrap (getArtists + all albums) /// 3. If lastSyncTimestamp exists → delta sync (ifModifiedSince) /// 4. Subsequent pulls run every 15 minutes in background /// /// All sync work happens off-main. LibraryCache writes trigger @Published changes /// which SwiftUI observes reactively. class SyncEngine: ObservableObject { static let shared = SyncEngine() @Published var isSyncing = false @Published var lastSyncDate: Date? @Published var syncProgress: String? private let cache = LibraryCache.shared private var syncTask: Task? private var periodicTimer: Timer? private let syncInterval: TimeInterval = 15 * 60 // 15 minutes private var lastSyncTimestamp: Int64 { get { Int64(UserDefaults.standard.integer(forKey: "sync_last_timestamp")) } set { UserDefaults.standard.set(Int(newValue), forKey: "sync_last_timestamp") } } private var cancellables = Set() private init() { if let ts = UserDefaults.standard.object(forKey: "sync_last_date") as? Date { lastSyncDate = ts } // Companion push events → immediate re-sync so edits/uploads appear instantly NotificationCenter.default.publisher(for: .companionMetadataUpdated) .merge(with: NotificationCenter.default.publisher(for: .companionTrackUploaded)) .receive(on: DispatchQueue.main) .debounce(for: .seconds(2), scheduler: DispatchQueue.main) .sink { [weak self] _ in // Paths may have changed due to file restructuring — invalidate song caches // so next fetch returns the fresh Navidrome paths instead of stale ones LibraryCache.shared.remove(key: "all_songs_sorted") LibraryCache.shared.removeAlbumDetails() self?.lastSyncTimestamp = 0 self?.syncIfNeeded() } .store(in: &cancellables) // Cover art updated — clear image cache so new art shows immediately NotificationCenter.default.publisher(for: .companionCoverArtUpdated) .receive(on: DispatchQueue.main) .sink { _ in ImageCache.shared.clearAll() } .store(in: &cancellables) } // MARK: - Public API /// Called on app launch — syncs if needed, then starts periodic refresh. func syncIfNeeded() { guard !isSyncing else { return } syncTask = Task { [weak self] in await self?.performSync() } startPeriodicSync() } /// Awaitable variant for BGTask handlers — suspends until sync completes or fails. /// BGTask handlers MUST await this before calling setTaskCompleted, otherwise the /// task is reported complete before any work has been done and iOS kills the process. func syncAndWait() async { guard !isSyncing else { // Already running — wait for it to finish by yielding until isSyncing clears while isSyncing { try? await Task.sleep(nanoseconds: 200_000_000) } return } await performSync() } private func performSync() async { await MainActor.run { self.isSyncing = true } do { if self.lastSyncTimestamp == 0 { try await self.fullBootstrap() } else { try await self.deltaSync() } let now = Date() await MainActor.run { self.lastSyncDate = now self.isSyncing = false self.syncProgress = nil UserDefaults.standard.set(now, forKey: "sync_last_date") } } catch { await MainActor.run { self.isSyncing = false self.syncProgress = nil } DebugLogger.shared.log("Sync failed: \(error.localizedDescription)", category: "Sync", level: .warning) // Do NOT reset lastSyncTimestamp on failure — keeps delta sync on next attempt // rather than trapping the app in an infinite full-bootstrap loop (AUDIT-059) } } /// Force a full re-sync (user-triggered) func forceSync() { lastSyncTimestamp = 0 syncTask?.cancel() syncTask = nil isSyncing = false syncIfNeeded() } /// Stop all sync activity func stop() { syncTask?.cancel() syncTask = nil periodicTimer?.invalidate() periodicTimer = nil } // MARK: - Full Bootstrap /// First-time sync: pulls entire library metadata and caches it. private func fullBootstrap() async throws { let client = ServerManager.shared.client await setProgress("Syncing artists...") DebugLogger.shared.log("Bootstrap: starting full sync", category: "Sync") // 1. Artists — use cacheArtists() which saves to "artists" key let artistIndexes = try await client.getArtists() cache.cacheArtists(artistIndexes) let artistCount = artistIndexes.flatMap { $0.artist ?? [] }.count await setProgress("Synced \(artistCount) artists") // 2. Genres — save to "genres" key (matches MyMusicView) let genres = try await client.getGenres() cache.save(genres, key: "genres") // 3. Albums — paginate to get everything var allAlbums: [Album] = [] var offset = 0 let pageSize = 500 while true { let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset) allAlbums.append(contentsOf: page) await setProgress("Albums: \(allAlbums.count)...") if page.count < pageSize { break } offset += pageSize } cache.save(allAlbums, key: "all_albums") DebugLogger.shared.log("Bootstrap: \(allAlbums.count) albums cached", category: "Sync") // 4. Recently Added — use cacheAlbums() which saves to "recent_albums" let recent = try await client.getAlbumList2(type: "newest", size: 30) cache.cacheAlbums(recent) // 5. Starred if let starred = try await client.getStarred2() { if let starredAlbums = starred.album { cache.save(starredAlbums, key: "starred_albums") } if let starredSongs = starred.song { cache.save(starredSongs, key: "starred_songs") } if let starredArtists = starred.artist { cache.save(starredArtists, key: "starred_artists") } } // 6. Playlists — use cachePlaylists() which saves to "playlists" key let playlists = try await client.getPlaylists() cache.cachePlaylists(playlists) lastSyncTimestamp = Int64(Date().timeIntervalSince1970) DebugLogger.shared.log("Bootstrap complete: \(artistCount) artists, \(allAlbums.count) albums, \(genres.count) genres", category: "Sync") // 7. Pre-cache album details in background (so tapping any album is instant) Task.detached(priority: .utility) { [weak self] in await self?.preCacheAlbumDetails(allAlbums) } // 8. Companion sync — overwrites album list with properly sorted data syncCompanion() } // MARK: - Delta Sync /// Incremental sync using ifModifiedSince where supported. /// Falls back to a light refresh of recently changed data. private func deltaSync() async throws { let client = ServerManager.shared.client let since = lastSyncTimestamp await setProgress("Checking for changes...") DebugLogger.shared.log("Delta sync: since \(since)", category: "Sync") let indexes = try await client.getIndexes(ifModifiedSince: since) if !indexes.isEmpty { let artistIndexes = try await client.getArtists() cache.cacheArtists(artistIndexes) let artistCount = artistIndexes.flatMap { $0.artist ?? [] }.count await setProgress("Updated \(artistCount) artists") var allAlbums: [Album] = [] var offset = 0 let pageSize = 500 while true { let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset) allAlbums.append(contentsOf: page) if page.count < pageSize { break } offset += pageSize } cache.save(allAlbums, key: "all_albums") // Pre-cache album details for new/changed albums Task.detached(priority: .utility) { [weak self] in await self?.preCacheAlbumDetails(allAlbums) } } let recent = try await client.getAlbumList2(type: "newest", size: 30) cache.cacheAlbums(recent) if let starred = try await client.getStarred2() { if let starredAlbums = starred.album { cache.save(starredAlbums, key: "starred_albums") } if let starredSongs = starred.song { cache.save(starredSongs, key: "starred_songs") } if let starredArtists = starred.artist { cache.save(starredArtists, key: "starred_artists") } } let playlists = try await client.getPlaylists() cache.cachePlaylists(playlists) let genres = try await client.getGenres() cache.save(genres, key: "genres") lastSyncTimestamp = Int64(Date().timeIntervalSince1970) DebugLogger.shared.log("Delta sync complete", category: "Sync") // Companion sync — keeps album sort order in sync after any library change syncCompanion() } // MARK: - Periodic private func startPeriodicSync() { periodicTimer?.invalidate() periodicTimer = Timer.scheduledTimer(withTimeInterval: syncInterval, repeats: true) { [weak self] _ in self?.syncIfNeeded() } } // MARK: - Album Detail Pre-Caching /// Fetches full album details (with songs) for every album and caches them. /// Runs in background at low priority — skips already-cached albums. /// The "already cached?" check hits LibraryCache's NSCache memory tier, /// so it's a pointer lookup — not disk I/O. private func preCacheAlbumDetails(_ albums: [Album]) async { let client = ServerManager.shared.client var cached = 0 var skipped = 0 for album in albums { // Skip if already cached — hits NSCache (memory), not disk if cache.loadAlbumDetail(id: album.id) != nil { skipped += 1 continue } do { if let detail = try await client.getAlbum(id: album.id) { cache.cacheAlbumDetail(detail) cached += 1 } } catch { // Non-fatal — will be fetched on demand } // Yield occasionally to not starve other tasks if (cached + skipped) % 20 == 0 { try? await Task.sleep(for: .milliseconds(100)) } } DebugLogger.shared.log("Album detail pre-cache: \(cached) new, \(skipped) already cached", category: "Sync") } // MARK: - Server Change /// Call when the active server changes — invalidates sync state so next sync is a full bootstrap func serverChanged() { lastSyncTimestamp = 0 lastSyncDate = nil cache.clearAll() DebugLogger.shared.log("Server changed — cache cleared, will re-bootstrap", category: "Sync") } // MARK: - Companion Sync (Phase 1) /// Kick off a Companion library sync in the background after Navidrome sync completes. func syncCompanion() { guard CompanionSettings.shared.isEnabled else { return } Task(priority: .utility) { [weak self] in do { try await self?.companionSync() } catch { DebugLogger.shared.log("Companion sync failed: \(error.localizedDescription)", category: "Companion") } } } private func companionSync() async throws { let service = CompanionAPIService.shared // Skip silently if Companion is unreachable guard (try? await service.healthCheck()) == true else { DebugLogger.shared.log("Companion unreachable — skipping library sync", category: "Companion") return } await setProgress("Syncing Companion library...") DebugLogger.shared.log("Companion sync started", category: "Companion") // Fetch albums from Companion for sorting/metadata purposes let companionAlbums = try await service.fetchAllAlbums() cache.cacheCompanionAlbums(companionAlbums) // Do NOT overwrite "all_albums" with Companion IDs. // Companion album IDs are synthetic ("companion:{name}|{artist}") and // unknown to Navidrome — AlbumDetailView would fail to stream any song // because getAlbum(id:) returns nothing for those IDs. // The Subsonic-fetched all_albums (real Navidrome IDs) stays as-is. DebugLogger.shared.log("Companion sync: \(companionAlbums.count) albums", category: "Companion") await MainActor.run { NotificationCenter.default.post(name: .companionLibraryChanged, object: nil) } } // MARK: - Helpers private func setProgress(_ msg: String) async { await MainActor.run { self.syncProgress = msg } } }