velody/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift

126 lines
3.9 KiB
Swift

import Foundation
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
public protocol RemoteLibraryRepository: Actor {
func loadCachedRemoteTracks() async throws -> [RemoteTrack]
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack]
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data
}
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,
syncCursorStore: any RemoteLibrarySyncCursorStore
) {
self.apiClient = apiClient
self.store = store
self.syncCursorStore = syncCursorStore
}
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
try await store.loadRemoteTracks()
}
public func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
_ = deviceId
if let currentCursor = try await syncCursorStore.loadCursor() {
return try await syncIncrementally(from: currentCursor)
}
return try await bootstrap()
}
public func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
try await apiClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId)
}
public func downloadArtwork(
artworkId: String,
deviceId: String
) 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
}
}
}