267 lines
9.8 KiB
Swift
267 lines
9.8 KiB
Swift
|
|
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<Void, Never>?
|
||
|
|
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 }
|
||
|
|
}
|
||
|
|
}
|