Implement incremental sync and offline recovery
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user