344 lines
14 KiB
Swift
344 lines
14 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 var cancellables = Set<AnyCancellable>()
|
|
|
|
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.
|
|
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: - 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 with correct sort order from Companion
|
|
let companionAlbums = try await service.fetchAllAlbums()
|
|
cache.cacheCompanionAlbums(companionAlbums)
|
|
|
|
// Overwrite standard album cache with Companion-sorted data so
|
|
// MyMusicView and search results immediately reflect correct ordering
|
|
let standardAlbums = companionAlbums.map { $0.toAlbum() }
|
|
cache.save(standardAlbums, key: "all_albums")
|
|
|
|
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 }
|
|
}
|
|
}
|