Implement incremental sync and offline recovery

This commit is contained in:
diyaa
2026-06-15 22:31:23 +02:00
parent fa7727d572
commit 295c6c1d9b
25 changed files with 2293 additions and 494 deletions
@@ -37,7 +37,18 @@ public actor PlaceholderSyncCoordinator: SyncCoordinator {
public func performInitialSync() async throws -> SyncResult {
let bootstrap = try await apiClient.fetchSyncBootstrap()
try await store.replaceTracks(bootstrap.tracks)
try await store.replaceTracks(
bootstrap.tracks.map { track in
LibraryTrack(
id: track.trackId,
title: track.title,
artist: track.artist,
durationSeconds: Double(track.durationSeconds),
localFilePath: "",
sha256: track.sha256
)
}
)
let persistedTracks = try await store.loadTracks()
return SyncResult(
@@ -13,13 +13,16 @@ public protocol RemoteLibraryRepository: Actor {
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
private let apiClient: any VelodyAPIClient
private let store: any RemoteLibraryStore
private let syncCursorStore: any RemoteLibrarySyncCursorStore
public init(
apiClient: any VelodyAPIClient,
store: any RemoteLibraryStore
store: any RemoteLibraryStore,
syncCursorStore: any RemoteLibrarySyncCursorStore
) {
self.apiClient = apiClient
self.store = store
self.syncCursorStore = syncCursorStore
}
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
@@ -27,10 +30,13 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
}
public func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
let response = try await apiClient.fetchRemoteLibrary(deviceId: deviceId)
let tracks = response.tracks.map(\.remoteTrack)
try await store.replaceRemoteTracks(tracks)
return tracks
_ = deviceId
if let currentCursor = try await syncCursorStore.loadCursor() {
return try await syncIncrementally(from: currentCursor)
}
return try await bootstrap()
}
public func downloadAudioAsset(
@@ -46,4 +52,74 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
) async throws -> Data {
try await apiClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId)
}
private func bootstrap() async throws -> [RemoteTrack] {
let response = try await apiClient.fetchSyncBootstrap()
let tracks = orderTracks(response.tracks)
try await store.replaceRemoteTracks(tracks)
try await syncCursorStore.saveCursor(response.nextCursor)
return tracks
}
private func syncIncrementally(
from cursor: SyncCursor
) async throws -> [RemoteTrack] {
let cachedTracks = try await store.loadRemoteTracks()
var mergedTracks = Dictionary(
uniqueKeysWithValues: cachedTracks.map { ($0.trackId, $0) }
)
var currentCursor = cursor
while true {
let response = try await apiClient.fetchSyncChanges(cursor: currentCursor)
if response.requiresBootstrap {
return try await bootstrap()
}
mergedTracks = apply(events: response.events, to: mergedTracks)
currentCursor = response.nextCursor
if !response.hasMore {
break
}
}
let orderedTracks = orderTracks(Array(mergedTracks.values))
try await store.replaceRemoteTracks(orderedTracks)
try await syncCursorStore.saveCursor(currentCursor)
return orderedTracks
}
private func apply(
events: [SyncEvent],
to tracksByID: [String: RemoteTrack]
) -> [String: RemoteTrack] {
var nextTracksByID = tracksByID
for event in events {
if let deletedTrackID = event.deletedTrackId, !deletedTrackID.isEmpty {
nextTracksByID.removeValue(forKey: deletedTrackID)
continue
}
guard let track = event.track else {
continue
}
nextTracksByID[track.trackId] = track
}
return nextTracksByID
}
private func orderTracks(_ tracks: [RemoteTrack]) -> [RemoteTrack] {
tracks.sorted { lhs, rhs in
if lhs.createdAt == rhs.createdAt {
return lhs.trackId < rhs.trackId
}
return lhs.createdAt < rhs.createdAt
}
}
}
@@ -26,7 +26,7 @@ public actor RemoteLibrarySyncService {
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
let states = try await downloadStateStore.loadDownloadStates()
return try await reconcileDownloadedLocalFilePaths(in: states)
return try await reconcilePersistedDownloadStates(in: states)
}
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
@@ -150,7 +150,7 @@ public actor RemoteLibrarySyncService {
)
}
private func reconcileDownloadedLocalFilePaths(
private func reconcilePersistedDownloadStates(
in states: [RemoteTrackDownloadState]
) async throws -> [RemoteTrackDownloadState] {
guard !states.isEmpty else {
@@ -162,19 +162,36 @@ public actor RemoteLibrarySyncService {
for index in reconciledStates.indices {
let state = reconciledStates[index]
guard state.downloadStatus == .downloaded else {
continue
}
guard let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
persistedLocalFilePath: state.localFilePath,
assetId: state.assetId
) else {
continue
}
)
if state.localFilePath != resolvedLocalFilePath {
reconciledStates[index].localFilePath = resolvedLocalFilePath
switch state.downloadStatus {
case .notDownloaded, .failed:
continue
case .downloaded:
guard let resolvedLocalFilePath else {
continue
}
if state.localFilePath != resolvedLocalFilePath {
reconciledStates[index].localFilePath = resolvedLocalFilePath
didChange = true
}
case .downloading:
if let resolvedLocalFilePath {
if state.localFilePath != resolvedLocalFilePath {
reconciledStates[index].localFilePath = resolvedLocalFilePath
}
reconciledStates[index].downloadStatus = .downloaded
reconciledStates[index].lastDownloadError = nil
} else {
reconciledStates[index].localFilePath = ""
reconciledStates[index].downloadedAt = nil
reconciledStates[index].downloadStatus = .failed
reconciledStates[index].lastDownloadError = Self.interruptedDownloadErrorMessage
}
didChange = true
}
}
@@ -186,6 +203,8 @@ public actor RemoteLibrarySyncService {
return reconciledStates
}
private static let interruptedDownloadErrorMessage = "The previous download did not finish. Try again."
private func cacheArtwork(
for tracks: [RemoteTrack],
deviceId: String