NavidromeApp/iOS/Data/SyncEngine.swift
Dallas Groot 8aa47319f2 quick fix
2026-04-12 13:21:20 -07:00

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