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 init() { if let ts = UserDefaults.standard.object(forKey: "sync_last_date") as? Date { lastSyncDate = ts } } // 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 guard let self else { return } await MainActor.run { self.isSyncing = true } do { if self.lastSyncTimestamp == 0 { // Never synced — full bootstrap try await self.fullBootstrap() } else { // Delta sync 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") } } startPeriodicSync() } /// 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) } } // 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") } // 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. 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 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: - Helpers private func setProgress(_ msg: String) async { await MainActor.run { self.syncProgress = msg } } }