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
@@ -476,13 +476,15 @@ final class iPhoneLibraryViewModel {
}
)
let store = Self.makeRemoteLibraryStore()
let syncCursorStore = Self.makeRemoteLibrarySyncCursorStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
let artworkStore = Self.makeArtworkStore()
let favoriteTrackStore = Self.makeFavoriteTrackStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
store: store,
syncCursorStore: syncCursorStore
)
let syncService = RemoteLibrarySyncService(
repository: repository,
@@ -726,6 +728,14 @@ final class iPhoneLibraryViewModel {
return InMemoryRemoteTrackDownloadStateStore()
}
private static func makeRemoteLibrarySyncCursorStore() -> any RemoteLibrarySyncCursorStore {
if let store = try? FileRemoteLibrarySyncCursorStore() {
return store
}
return InMemoryRemoteLibrarySyncCursorStore()
}
private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore {
if let store = try? FileOfflineAudioFileStore() {
return store
@@ -153,6 +153,36 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite)
}
func testFavoritesRemainIntactAcrossLibrarySync() async throws {
let track = makeRemoteTrack(
trackId: "remote-sync-favorite",
assetId: "asset-sync-favorite",
title: "Sync Favorite"
)
let favoriteStore = InMemoryFavoriteTrackStore()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: favoriteStore,
audioFiles: [localFilePath(for: track): Data([0x5])],
apiClient: StubVelodyAPIClient(
environment: ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "Tests"
)
)
)
await viewModel.loadIfNeeded()
await viewModel.toggleFavorite(trackID: track.trackId)
await viewModel.refreshSync()
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
let savedFavorites = try await favoriteStore.loadFavoriteTracks()
XCTAssertEqual(savedFavorites.map(\.remoteTrackId), [track.trackId])
}
func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws {
let track = makeRemoteTrack(
trackId: "remote-repeat",
@@ -285,4 +315,41 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
XCTAssertTrue(remoteTrack.isFavorite)
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
}
func testFailedDownloadKeepsFavoriteAndShowsRetry() async throws {
let track = makeRemoteTrack(
trackId: "remote-interrupted-favorite",
assetId: "asset-interrupted-favorite",
title: "Interrupted Favorite"
)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [
RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: "",
downloadedAt: nil,
downloadStatus: .failed,
lastDownloadError: "The previous download did not finish. Try again."
),
],
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
FavoriteTrackRecord(
remoteTrackId: track.trackId,
favoritedAt: Date(timeIntervalSince1970: 7_000)
),
])
)
await viewModel.loadIfNeeded()
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
XCTAssertEqual(remoteTrack.status, .failed)
XCTAssertEqual(remoteTrack.downloadButtonTitle, "Retry")
XCTAssertTrue(remoteTrack.canDownload)
XCTAssertTrue(remoteTrack.isFavorite)
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
}
}
@@ -4,6 +4,7 @@ import VelodyDomain
import VelodyNetworking
import VelodyPlayback
import VelodyPersistence
import VelodyUtilities
@testable import VelodyiPhone
@MainActor
@@ -449,9 +450,35 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase {
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
XCTAssertEqual(remoteTrack.status, .missing)
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Missing")
XCTAssertTrue(remoteTrack.canDownload)
XCTAssertEqual(remoteTrack.downloadButtonTitle, "Re-download")
XCTAssertFalse(remoteTrack.canPlay)
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)")
}
func testDownloadedTrackAppearsInAvailableOfflineState() async throws {
let track = makeRemoteTrack(
trackId: "remote-recovered-download",
assetId: "asset-recovered-download",
title: "Recovered Download"
)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])]
)
await viewModel.loadIfNeeded()
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId))
XCTAssertEqual(remoteTrack.status, .downloaded)
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Downloaded")
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (1)")
}
}
@MainActor
@@ -479,7 +506,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
let registerCount = await counter.currentCount()
XCTAssertEqual(registerCount, 1)
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
@@ -513,7 +541,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
forKey: "velody.iphone.device-access-token"
)
XCTAssertEqual(await counter.count, 1)
let registerCount = await counter.currentCount()
XCTAssertEqual(registerCount, 1)
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
}
@@ -525,6 +554,10 @@ private actor RegisterCallCounter {
func increment() {
count += 1
}
func currentCount() -> Int {
count
}
}
private struct TestRegisterAPIClient: VelodyAPIClient {
@@ -563,6 +596,12 @@ private struct TestRegisterAPIClient: VelodyAPIClient {
try await stubClient.fetchSyncBootstrap()
}
func fetchSyncChanges(
cursor: SyncCursor
) async throws -> SyncChangesResponse {
try await stubClient.fetchSyncChanges(cursor: cursor)
}
func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {