Add offline resilience coverage
This commit is contained in:
@@ -0,0 +1,635 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import XCTest
|
||||
import VelodyDomain
|
||||
import VelodyNetworking
|
||||
import VelodyPersistence
|
||||
import VelodySync
|
||||
import VelodyUtilities
|
||||
@testable import VelodyiPhone
|
||||
|
||||
@MainActor
|
||||
final class iPhoneLibraryViewModelOfflineResilienceTests: XCTestCase {
|
||||
func testRelaunchWhileBackendUnavailableRestoresFavoriteDownloadedArtworkAndOfflinePlayback() async throws {
|
||||
let fixture = try await seedPersistedLibrary()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
||||
}
|
||||
|
||||
let player = TestPlayer()
|
||||
let offlineClient = ScenarioVelodyAPIClient(
|
||||
bootstrapResponse: fixture.bootstrapResponse,
|
||||
changeError: .requestFailed("Offline")
|
||||
)
|
||||
let relaunchedViewModel = try makePersistedViewModel(
|
||||
fixture: fixture,
|
||||
apiClient: offlineClient,
|
||||
player: player
|
||||
)
|
||||
|
||||
await relaunchedViewModel.loadIfNeeded()
|
||||
|
||||
let remoteTrack = try XCTUnwrap(
|
||||
remoteRow(in: relaunchedViewModel, trackID: fixture.track.trackId)
|
||||
)
|
||||
let offlineTrack = try XCTUnwrap(
|
||||
offlineRow(in: relaunchedViewModel, trackID: fixture.track.trackId)
|
||||
)
|
||||
|
||||
XCTAssertEqual(relaunchedViewModel.syncStatus, "Library restored from local cache.")
|
||||
XCTAssertTrue(remoteTrack.isFavorite)
|
||||
XCTAssertEqual(remoteTrack.status, .downloaded)
|
||||
XCTAssertEqual(remoteTrack.artworkLocalFilePath, fixture.initialArtworkPath)
|
||||
XCTAssertTrue(offlineTrack.isFavorite)
|
||||
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
|
||||
|
||||
relaunchedViewModel.togglePlayback(trackID: fixture.track.trackId)
|
||||
|
||||
let nowPlayingCard = try XCTUnwrap(relaunchedViewModel.nowPlayingCard)
|
||||
XCTAssertEqual(relaunchedViewModel.nowPlaying.trackID, fixture.track.trackId)
|
||||
XCTAssertEqual(relaunchedViewModel.nowPlaying.playbackState, .playing)
|
||||
XCTAssertEqual(nowPlayingCard.artworkLocalFilePath, fixture.initialArtworkPath)
|
||||
XCTAssertEqual(nowPlayingCard.downloadBadge, .downloaded)
|
||||
XCTAssertEqual(nowPlayingCard.playbackStateText, "Playing")
|
||||
}
|
||||
|
||||
func testBackendFailureAfterRelaunchKeepsCachedLibraryUsableAndReusesPersistedCursor() async throws {
|
||||
let fixture = try await seedPersistedLibrary()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
||||
}
|
||||
|
||||
let offlineClient = ScenarioVelodyAPIClient(
|
||||
bootstrapResponse: fixture.bootstrapResponse,
|
||||
changeError: .requestFailed("Offline")
|
||||
)
|
||||
let relaunchedViewModel = try makePersistedViewModel(
|
||||
fixture: fixture,
|
||||
apiClient: offlineClient,
|
||||
player: TestPlayer()
|
||||
)
|
||||
|
||||
await relaunchedViewModel.loadIfNeeded()
|
||||
await relaunchedViewModel.refreshSync()
|
||||
|
||||
XCTAssertEqual(
|
||||
relaunchedViewModel.syncStatus,
|
||||
"Could not reach the backend. Check that the server is running and try again."
|
||||
)
|
||||
XCTAssertEqual(relaunchedViewModel.remoteTracks.map(\.id), [fixture.track.trackId])
|
||||
XCTAssertEqual(relaunchedViewModel.availableOfflineTracks.map(\.id), [fixture.track.trackId])
|
||||
let recordedChangeCursors = await offlineClient.recordedChangeCursors()
|
||||
XCTAssertEqual(recordedChangeCursors, ["1"])
|
||||
let persistedCursor = try await FileRemoteLibrarySyncCursorStore(
|
||||
fileURL: fixture.syncCursorFileURL
|
||||
).loadCursor()
|
||||
XCTAssertEqual(persistedCursor, SyncCursor(value: "1"))
|
||||
}
|
||||
|
||||
func testArtworkReplacementSyncKeepsFavoriteAndDownloadedState() async throws {
|
||||
let fixture = try await seedPersistedLibrary()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
||||
}
|
||||
|
||||
let replacementArtwork = RemoteArtwork(
|
||||
artworkId: "artwork-replacement",
|
||||
sha256: sha256Hex(sampleArtworkData(seed: "replacement-artwork")),
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
var replacementTrack = fixture.track
|
||||
replacementTrack.updatedAt = "2026-06-16T09:30:00.000Z"
|
||||
replacementTrack.artwork = replacementArtwork
|
||||
let replacementArtworkPath = expectedArtworkFilePath(
|
||||
in: fixture.artworkDirectory,
|
||||
artwork: replacementArtwork
|
||||
)
|
||||
let refreshClient = ScenarioVelodyAPIClient(
|
||||
bootstrapResponse: fixture.bootstrapResponse,
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "2"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "2"),
|
||||
entityType: "ARTWORK_ASSET",
|
||||
entityId: replacementArtwork.artworkId,
|
||||
action: "UPDATED",
|
||||
track: replacementTrack,
|
||||
createdAt: "2026-06-16T09:30:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-16T09:30:00.000Z"
|
||||
),
|
||||
],
|
||||
artworkDataByArtworkID: [
|
||||
replacementArtwork.artworkId: sampleArtworkData(seed: "replacement-artwork"),
|
||||
]
|
||||
)
|
||||
let viewModel = try makePersistedViewModel(
|
||||
fixture: fixture,
|
||||
apiClient: refreshClient,
|
||||
player: TestPlayer()
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
await viewModel.refreshSync()
|
||||
|
||||
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: fixture.track.trackId))
|
||||
let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: fixture.track.trackId))
|
||||
|
||||
XCTAssertTrue(remoteTrack.isFavorite)
|
||||
XCTAssertEqual(remoteTrack.status, .downloaded)
|
||||
XCTAssertEqual(remoteTrack.artworkLocalFilePath, replacementArtworkPath)
|
||||
XCTAssertNotEqual(remoteTrack.artworkLocalFilePath, fixture.initialArtworkPath)
|
||||
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
|
||||
|
||||
viewModel.togglePlayback(trackID: fixture.track.trackId)
|
||||
|
||||
let nowPlayingCard = try XCTUnwrap(viewModel.nowPlayingCard)
|
||||
XCTAssertEqual(nowPlayingCard.artworkLocalFilePath, replacementArtworkPath)
|
||||
XCTAssertEqual(nowPlayingCard.downloadBadge, .downloaded)
|
||||
}
|
||||
|
||||
func testAssetReplacementSyncKeepsFavoriteAndDownloadedState() async throws {
|
||||
let fixture = try await seedPersistedLibrary()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
||||
}
|
||||
|
||||
let replacementAssetId = "asset-replacement"
|
||||
var replacementTrack = makeRemoteTrack(
|
||||
trackId: fixture.track.trackId,
|
||||
assetId: replacementAssetId,
|
||||
title: fixture.track.title,
|
||||
artwork: fixture.track.artwork
|
||||
)
|
||||
replacementTrack.sha256 = sha256Hex(sampleAudioData(seed: replacementAssetId))
|
||||
replacementTrack.updatedAt = "2026-06-16T10:00:00.000Z"
|
||||
let refreshClient = ScenarioVelodyAPIClient(
|
||||
bootstrapResponse: fixture.bootstrapResponse,
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "2"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "2"),
|
||||
entityType: "AUDIO_ASSET",
|
||||
entityId: replacementAssetId,
|
||||
action: "UPDATED",
|
||||
track: replacementTrack,
|
||||
createdAt: "2026-06-16T10:00:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-16T10:00:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let viewModel = try makePersistedViewModel(
|
||||
fixture: fixture,
|
||||
apiClient: refreshClient,
|
||||
player: TestPlayer()
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
await viewModel.refreshSync()
|
||||
|
||||
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: fixture.track.trackId))
|
||||
let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: fixture.track.trackId))
|
||||
|
||||
XCTAssertTrue(remoteTrack.isFavorite)
|
||||
XCTAssertEqual(remoteTrack.status, .downloaded)
|
||||
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
|
||||
XCTAssertEqual(viewModel.availableOfflineTracks.map(\.id), [fixture.track.trackId])
|
||||
|
||||
viewModel.togglePlayback(trackID: fixture.track.trackId)
|
||||
|
||||
XCTAssertEqual(viewModel.nowPlaying.trackID, fixture.track.trackId)
|
||||
XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing)
|
||||
}
|
||||
|
||||
func testTrackDeletionSyncRemovesDeletedTrackGracefully() async throws {
|
||||
let fixture = try await seedPersistedLibrary()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
||||
}
|
||||
|
||||
let refreshClient = ScenarioVelodyAPIClient(
|
||||
bootstrapResponse: fixture.bootstrapResponse,
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "2"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "2"),
|
||||
entityType: "TRACK",
|
||||
entityId: fixture.track.trackId,
|
||||
action: "DELETED",
|
||||
deletedTrackId: fixture.track.trackId,
|
||||
createdAt: "2026-06-16T10:30:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-16T10:30:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let viewModel = try makePersistedViewModel(
|
||||
fixture: fixture,
|
||||
apiClient: refreshClient,
|
||||
player: TestPlayer()
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
await viewModel.refreshSync()
|
||||
|
||||
XCTAssertTrue(viewModel.remoteTracks.isEmpty)
|
||||
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
||||
XCTAssertEqual(viewModel.syncStatus, "No music was returned for this library.")
|
||||
}
|
||||
}
|
||||
|
||||
private struct PersistedOfflineLibraryFixture {
|
||||
let tempDirectory: URL
|
||||
let remoteLibraryFileURL: URL
|
||||
let downloadStateFileURL: URL
|
||||
let syncCursorFileURL: URL
|
||||
let favoriteTrackFileURL: URL
|
||||
let audioDirectory: URL
|
||||
let artworkDirectory: URL
|
||||
let keychainService: MemoryKeychainService
|
||||
let track: RemoteTrack
|
||||
let bootstrapResponse: SyncBootstrapResponse
|
||||
let initialArtworkPath: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func seedPersistedLibrary() async throws -> PersistedOfflineLibraryFixture {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json")
|
||||
let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
||||
let syncCursorFileURL = tempDirectory.appendingPathComponent("remote-library-sync-cursor.json")
|
||||
let favoriteTrackFileURL = tempDirectory.appendingPathComponent("favorite-tracks.json")
|
||||
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
|
||||
let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true)
|
||||
let keychainService = MemoryKeychainService()
|
||||
let artworkData = sampleArtworkData(seed: "seed-artwork")
|
||||
let artwork = RemoteArtwork(
|
||||
artworkId: "artwork-seed",
|
||||
sha256: sha256Hex(artworkData),
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
let track = makeConsistentRemoteTrack(
|
||||
trackId: "remote-offline-resilience",
|
||||
assetId: "asset-offline-resilience",
|
||||
title: "Offline Resilience",
|
||||
artwork: artwork
|
||||
)
|
||||
let bootstrapResponse = SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "1"),
|
||||
tracks: [track],
|
||||
serverTime: "2026-06-16T09:00:00.000Z"
|
||||
)
|
||||
let onlineClient = ScenarioVelodyAPIClient(
|
||||
bootstrapResponse: bootstrapResponse,
|
||||
audioAssetDataByAssetID: [
|
||||
track.assetId: sampleAudioData(seed: track.assetId),
|
||||
],
|
||||
artworkDataByArtworkID: [
|
||||
artwork.artworkId: artworkData,
|
||||
]
|
||||
)
|
||||
let firstViewModel = try makePersistedViewModel(
|
||||
remoteLibraryFileURL: remoteLibraryFileURL,
|
||||
downloadStateFileURL: downloadStateFileURL,
|
||||
syncCursorFileURL: syncCursorFileURL,
|
||||
favoriteTrackFileURL: favoriteTrackFileURL,
|
||||
audioDirectory: audioDirectory,
|
||||
artworkDirectory: artworkDirectory,
|
||||
apiClient: onlineClient,
|
||||
keychainService: keychainService,
|
||||
player: TestPlayer()
|
||||
)
|
||||
|
||||
await firstViewModel.loadIfNeeded()
|
||||
await firstViewModel.refreshSync()
|
||||
await firstViewModel.downloadTrack(trackID: track.trackId)
|
||||
await firstViewModel.toggleFavorite(trackID: track.trackId)
|
||||
|
||||
return PersistedOfflineLibraryFixture(
|
||||
tempDirectory: tempDirectory,
|
||||
remoteLibraryFileURL: remoteLibraryFileURL,
|
||||
downloadStateFileURL: downloadStateFileURL,
|
||||
syncCursorFileURL: syncCursorFileURL,
|
||||
favoriteTrackFileURL: favoriteTrackFileURL,
|
||||
audioDirectory: audioDirectory,
|
||||
artworkDirectory: artworkDirectory,
|
||||
keychainService: keychainService,
|
||||
track: track,
|
||||
bootstrapResponse: bootstrapResponse,
|
||||
initialArtworkPath: expectedArtworkFilePath(in: artworkDirectory, artwork: artwork)
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makePersistedViewModel(
|
||||
fixture: PersistedOfflineLibraryFixture,
|
||||
apiClient: any VelodyAPIClient,
|
||||
player: any iPhoneLocalAudioPlaying
|
||||
) throws -> iPhoneLibraryViewModel {
|
||||
try makePersistedViewModel(
|
||||
remoteLibraryFileURL: fixture.remoteLibraryFileURL,
|
||||
downloadStateFileURL: fixture.downloadStateFileURL,
|
||||
syncCursorFileURL: fixture.syncCursorFileURL,
|
||||
favoriteTrackFileURL: fixture.favoriteTrackFileURL,
|
||||
audioDirectory: fixture.audioDirectory,
|
||||
artworkDirectory: fixture.artworkDirectory,
|
||||
apiClient: apiClient,
|
||||
keychainService: fixture.keychainService,
|
||||
player: player
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makePersistedViewModel(
|
||||
remoteLibraryFileURL: URL,
|
||||
downloadStateFileURL: URL,
|
||||
syncCursorFileURL: URL,
|
||||
favoriteTrackFileURL: URL,
|
||||
audioDirectory: URL,
|
||||
artworkDirectory: URL,
|
||||
apiClient: any VelodyAPIClient,
|
||||
keychainService: any KeychainService,
|
||||
player: any iPhoneLocalAudioPlaying
|
||||
) throws -> iPhoneLibraryViewModel {
|
||||
let environment = ServerEnvironment(
|
||||
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||
appVersion: "Tests"
|
||||
)
|
||||
let repository = DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
syncCursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
|
||||
)
|
||||
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||
let artworkStore = try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: repository,
|
||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL),
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
let offlineLibraryService = OfflineLibraryService(
|
||||
syncService: syncService,
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
|
||||
return iPhoneLibraryViewModel(
|
||||
environment: environment,
|
||||
apiClient: apiClient,
|
||||
syncService: syncService,
|
||||
offlineLibraryService: offlineLibraryService,
|
||||
favoriteTrackStore: try FileFavoriteTrackStore(fileURL: favoriteTrackFileURL),
|
||||
player: player,
|
||||
keychainService: keychainService
|
||||
)
|
||||
}
|
||||
|
||||
private actor ScenarioVelodyAPIClient: VelodyAPIClient {
|
||||
private let bootstrapResponse: SyncBootstrapResponse
|
||||
private var changeResponses: [SyncChangesResponse]
|
||||
private let registerError: VelodyAPIError?
|
||||
private let changeError: VelodyAPIError?
|
||||
private let audioAssetDataByAssetID: [String: Data]
|
||||
private let artworkDataByArtworkID: [String: Data]
|
||||
private var observedChangeCursors: [String] = []
|
||||
|
||||
init(
|
||||
bootstrapResponse: SyncBootstrapResponse,
|
||||
changeResponses: [SyncChangesResponse] = [],
|
||||
registerError: VelodyAPIError? = nil,
|
||||
changeError: VelodyAPIError? = nil,
|
||||
audioAssetDataByAssetID: [String: Data] = [:],
|
||||
artworkDataByArtworkID: [String: Data] = [:]
|
||||
) {
|
||||
self.bootstrapResponse = bootstrapResponse
|
||||
self.changeResponses = changeResponses
|
||||
self.registerError = registerError
|
||||
self.changeError = changeError
|
||||
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
||||
self.artworkDataByArtworkID = artworkDataByArtworkID
|
||||
}
|
||||
|
||||
func registerDevice(
|
||||
_ payload: DeviceRegistrationPayload
|
||||
) async throws -> DeviceRegistrationResponse {
|
||||
_ = payload
|
||||
|
||||
if let registerError {
|
||||
throw registerError
|
||||
}
|
||||
|
||||
return DeviceRegistrationResponse(
|
||||
deviceId: "device-offline-resilience",
|
||||
deviceAccessToken: "token-offline-resilience",
|
||||
bootstrapToken: "bootstrap-offline-resilience",
|
||||
serverTime: bootstrapResponse.serverTime
|
||||
)
|
||||
}
|
||||
|
||||
func sendHeartbeat(
|
||||
_ payload: DeviceHeartbeatPayload
|
||||
) async throws -> DeviceHeartbeatResponse {
|
||||
_ = payload
|
||||
|
||||
return DeviceHeartbeatResponse(
|
||||
ok: true,
|
||||
serverTime: bootstrapResponse.serverTime
|
||||
)
|
||||
}
|
||||
|
||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
||||
bootstrapResponse
|
||||
}
|
||||
|
||||
func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse {
|
||||
observedChangeCursors.append(cursor.value)
|
||||
|
||||
if let changeError {
|
||||
throw changeError
|
||||
}
|
||||
|
||||
guard !changeResponses.isEmpty else {
|
||||
return SyncChangesResponse(
|
||||
nextCursor: cursor,
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [],
|
||||
serverTime: bootstrapResponse.serverTime
|
||||
)
|
||||
}
|
||||
|
||||
return changeResponses.removeFirst()
|
||||
}
|
||||
|
||||
func fetchRemoteLibrary(
|
||||
deviceId: String
|
||||
) async throws -> RemoteLibraryResponseDTO {
|
||||
_ = deviceId
|
||||
|
||||
return RemoteLibraryResponseDTO(
|
||||
tracks: bootstrapResponse.tracks.map { track in
|
||||
RemoteTrackDTO(
|
||||
trackId: track.trackId,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
durationSeconds: track.durationSeconds,
|
||||
sha256: track.sha256,
|
||||
assetId: track.assetId,
|
||||
createdAt: track.createdAt,
|
||||
updatedAt: track.updatedAt,
|
||||
artwork: track.artwork.map { artwork in
|
||||
RemoteArtworkDTO(
|
||||
artworkId: artwork.artworkId,
|
||||
sha256: artwork.sha256,
|
||||
mimeType: artwork.mimeType,
|
||||
width: artwork.width,
|
||||
height: artwork.height
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func downloadAudioAsset(
|
||||
assetId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data {
|
||||
_ = deviceId
|
||||
return audioAssetDataByAssetID[assetId] ?? Data()
|
||||
}
|
||||
|
||||
func downloadArtwork(
|
||||
artworkId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data {
|
||||
_ = deviceId
|
||||
return artworkDataByArtworkID[artworkId] ?? Data()
|
||||
}
|
||||
|
||||
func prepareUpload(
|
||||
_ payload: UploadPrepareRequest
|
||||
) async throws -> UploadPrepareResponse {
|
||||
_ = payload
|
||||
|
||||
return UploadPrepareResponse(
|
||||
status: .uploadRequired,
|
||||
uploadId: UUID().uuidString,
|
||||
nextOffset: 0
|
||||
)
|
||||
}
|
||||
|
||||
func fetchUploadStatus(
|
||||
uploadId: String
|
||||
) async throws -> UploadSessionStatusResponse {
|
||||
UploadSessionStatusResponse(
|
||||
uploadId: uploadId,
|
||||
status: .completed,
|
||||
receivedBytes: "0",
|
||||
expectedSizeBytes: "0",
|
||||
nextOffset: "0"
|
||||
)
|
||||
}
|
||||
|
||||
func uploadFile(
|
||||
uploadId: String,
|
||||
fileURL: URL,
|
||||
mimeType: String
|
||||
) async throws -> UploadSessionStatusResponse {
|
||||
_ = fileURL
|
||||
_ = mimeType
|
||||
|
||||
return UploadSessionStatusResponse(
|
||||
uploadId: uploadId,
|
||||
status: .completed,
|
||||
receivedBytes: "0",
|
||||
expectedSizeBytes: "0",
|
||||
nextOffset: "0"
|
||||
)
|
||||
}
|
||||
|
||||
func finalizeUpload(
|
||||
uploadId: String,
|
||||
payload: UploadFinalizeRequest
|
||||
) async throws -> UploadFinalizeResponse {
|
||||
_ = payload
|
||||
|
||||
return UploadFinalizeResponse(
|
||||
trackId: uploadId,
|
||||
assetId: uploadId
|
||||
)
|
||||
}
|
||||
|
||||
func recordedChangeCursors() -> [String] {
|
||||
observedChangeCursors
|
||||
}
|
||||
}
|
||||
|
||||
private func makeConsistentRemoteTrack(
|
||||
trackId: String,
|
||||
assetId: String,
|
||||
title: String,
|
||||
artwork: RemoteArtwork?
|
||||
) -> RemoteTrack {
|
||||
var track = makeRemoteTrack(
|
||||
trackId: trackId,
|
||||
assetId: assetId,
|
||||
title: title,
|
||||
artwork: artwork
|
||||
)
|
||||
track.sha256 = sha256Hex(sampleAudioData(seed: assetId))
|
||||
return track
|
||||
}
|
||||
|
||||
private func sampleAudioData(seed: String) -> Data {
|
||||
Data([
|
||||
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
|
||||
] + Array(seed.utf8))
|
||||
}
|
||||
|
||||
private func sampleArtworkData(seed: String) -> Data {
|
||||
Data([
|
||||
0x89, 0x50, 0x4E, 0x47,
|
||||
] + Array(seed.utf8))
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func expectedArtworkFilePath(
|
||||
in artworkDirectory: URL,
|
||||
artwork: RemoteArtwork
|
||||
) -> String {
|
||||
artworkDirectory
|
||||
.appendingPathComponent("\(artwork.artworkId).png")
|
||||
.standardizedFileURL
|
||||
.path
|
||||
}
|
||||
Reference in New Issue
Block a user