Implement artwork download and cache
This commit is contained in:
@@ -141,11 +141,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: repository,
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: relaunchedAudioStore
|
||||
audioFileStore: relaunchedAudioStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
let offlineLibraryService = OfflineLibraryService(
|
||||
syncService: syncService,
|
||||
audioFileStore: relaunchedAudioStore
|
||||
audioFileStore: relaunchedAudioStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
|
||||
let snapshot = try await offlineLibraryService.loadSnapshot()
|
||||
@@ -177,11 +179,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
store: remoteLibraryStore
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
let offlineLibraryService = OfflineLibraryService(
|
||||
syncService: syncService,
|
||||
audioFileStore: audioFileStore
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
|
||||
defer {
|
||||
@@ -212,10 +216,12 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
isDirectory: true
|
||||
)
|
||||
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
|
||||
let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true)
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-sync",
|
||||
assetId: "asset-sync",
|
||||
title: "Sync Safe"
|
||||
title: "Sync Safe",
|
||||
artworkId: "artwork-sync"
|
||||
)
|
||||
let remoteLibraryStore = InMemoryRemoteLibraryStore()
|
||||
let audioData = sampleMp3Data(seed: track.assetId)
|
||||
@@ -223,21 +229,27 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||
tracks: [makeRemoteTrackDTO(from: track)]
|
||||
),
|
||||
audioAssetData: audioData
|
||||
audioAssetData: audioData,
|
||||
artworkDataByArtworkID: [
|
||||
"artwork-sync": sampleArtworkData(),
|
||||
]
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||
let artworkStore = try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: remoteLibraryStore
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
let offlineLibraryService = OfflineLibraryService(
|
||||
syncService: syncService,
|
||||
audioFileStore: audioFileStore
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
|
||||
defer {
|
||||
@@ -254,6 +266,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
XCTAssertEqual(beforeResync.availableTracks.map(\.remoteTrackId), [track.trackId])
|
||||
XCTAssertEqual(afterResync.availableTracks.map(\.remoteTrackId), [track.trackId])
|
||||
XCTAssertEqual(afterResync.remoteTracks.first?.status, .downloaded)
|
||||
XCTAssertEqual(beforeResync.remoteTracks.first?.localArtworkFilePath, afterResync.remoteTracks.first?.localArtworkFilePath)
|
||||
XCTAssertEqual(beforeResync.availableTracks.first?.localArtworkFilePath, afterResync.availableTracks.first?.localArtworkFilePath)
|
||||
}
|
||||
|
||||
func testRelaunchSimulationRebuildsOfflineLibraryAccurately() async throws {
|
||||
@@ -266,8 +280,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
||||
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
|
||||
let tracks = [
|
||||
makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1"),
|
||||
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2"),
|
||||
makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1", artworkId: "artwork-1"),
|
||||
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"),
|
||||
]
|
||||
let apiClient = OfflineLibraryMockAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||
@@ -276,8 +290,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
audioAssetDataByAssetID: [
|
||||
"asset-1": sampleMp3Data(seed: "asset-1"),
|
||||
"asset-2": sampleMp3Data(seed: "asset-2"),
|
||||
],
|
||||
artworkDataByArtworkID: [
|
||||
"artwork-1": sampleArtworkData(),
|
||||
"artwork-2": sampleArtworkData(),
|
||||
]
|
||||
)
|
||||
let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true)
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
@@ -292,11 +311,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let firstSyncService = RemoteLibrarySyncService(
|
||||
repository: firstRepository,
|
||||
downloadStateStore: firstDownloadStateStore,
|
||||
audioFileStore: firstAudioStore
|
||||
audioFileStore: firstAudioStore,
|
||||
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||
)
|
||||
let firstOfflineLibraryService = OfflineLibraryService(
|
||||
syncService: firstSyncService,
|
||||
audioFileStore: firstAudioStore
|
||||
audioFileStore: firstAudioStore,
|
||||
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||
)
|
||||
|
||||
_ = try await firstSyncService.syncRemoteLibrary(deviceId: "device-123")
|
||||
@@ -315,11 +336,13 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let relaunchedSyncService = RemoteLibrarySyncService(
|
||||
repository: relaunchedRepository,
|
||||
downloadStateStore: relaunchedDownloadStateStore,
|
||||
audioFileStore: relaunchedAudioStore
|
||||
audioFileStore: relaunchedAudioStore,
|
||||
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||
)
|
||||
let relaunchedOfflineLibraryService = OfflineLibraryService(
|
||||
syncService: relaunchedSyncService,
|
||||
audioFileStore: relaunchedAudioStore
|
||||
audioFileStore: relaunchedAudioStore,
|
||||
artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
||||
)
|
||||
|
||||
let afterRelaunch = try await relaunchedOfflineLibraryService.loadSnapshot()
|
||||
@@ -327,6 +350,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
XCTAssertEqual(beforeRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
|
||||
XCTAssertEqual(afterRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
|
||||
XCTAssertEqual(afterRelaunch.remoteTracks.map(\.status), [.downloaded, .downloaded])
|
||||
XCTAssertEqual(afterRelaunch.remoteTracks.compactMap(\.localArtworkFilePath).count, 2)
|
||||
XCTAssertEqual(afterRelaunch.availableTracks.compactMap(\.localArtworkFilePath).count, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,12 +363,14 @@ private func makeOfflineLibraryService(
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: InMemoryRemoteLibraryRepository(tracks: remoteTracks),
|
||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(states: downloadStates),
|
||||
audioFileStore: audioFileStore
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
|
||||
return OfflineLibraryService(
|
||||
syncService: syncService,
|
||||
audioFileStore: audioFileStore
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,21 +395,30 @@ private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository {
|
||||
_ = deviceId
|
||||
return Data()
|
||||
}
|
||||
|
||||
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
|
||||
_ = artworkId
|
||||
_ = deviceId
|
||||
return Data()
|
||||
}
|
||||
}
|
||||
|
||||
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
||||
let audioAssetData: Data?
|
||||
let audioAssetDataByAssetID: [String: Data]
|
||||
let artworkDataByArtworkID: [String: Data]
|
||||
|
||||
init(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||
audioAssetData: Data? = nil,
|
||||
audioAssetDataByAssetID: [String: Data] = [:]
|
||||
audioAssetDataByAssetID: [String: Data] = [:],
|
||||
artworkDataByArtworkID: [String: Data] = [:]
|
||||
) {
|
||||
self.remoteLibraryResponse = remoteLibraryResponse
|
||||
self.audioAssetData = audioAssetData
|
||||
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
||||
self.artworkDataByArtworkID = artworkDataByArtworkID
|
||||
}
|
||||
|
||||
func registerDevice(
|
||||
@@ -431,6 +467,14 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
return audioAssetDataByAssetID[assetId] ?? audioAssetData ?? Data()
|
||||
}
|
||||
|
||||
func downloadArtwork(
|
||||
artworkId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data {
|
||||
_ = deviceId
|
||||
return artworkDataByArtworkID[artworkId] ?? Data()
|
||||
}
|
||||
|
||||
func prepareUpload(
|
||||
_ payload: UploadPrepareRequest
|
||||
) async throws -> UploadPrepareResponse {
|
||||
@@ -483,7 +527,8 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
private func makeRemoteTrack(
|
||||
trackId: String,
|
||||
assetId: String,
|
||||
title: String
|
||||
title: String,
|
||||
artworkId: String? = nil
|
||||
) -> RemoteTrack {
|
||||
let bytes = sampleMp3Data(seed: assetId)
|
||||
|
||||
@@ -495,7 +540,16 @@ private func makeRemoteTrack(
|
||||
sha256: sha256Hex(bytes),
|
||||
assetId: assetId,
|
||||
createdAt: "2026-05-30T08:00:00.000Z",
|
||||
updatedAt: "2026-05-30T08:05:00.000Z"
|
||||
updatedAt: "2026-05-30T08:05:00.000Z",
|
||||
artwork: artworkId.map {
|
||||
RemoteArtwork(
|
||||
artworkId: $0,
|
||||
sha256: String(repeating: "c", count: 64),
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -508,7 +562,16 @@ private func makeRemoteTrackDTO(from track: RemoteTrack) -> RemoteTrackDTO {
|
||||
sha256: track.sha256,
|
||||
assetId: track.assetId,
|
||||
createdAt: track.createdAt,
|
||||
updatedAt: track.updatedAt
|
||||
updatedAt: track.updatedAt,
|
||||
artwork: track.artwork.map {
|
||||
RemoteArtworkDTO(
|
||||
artworkId: $0.artworkId,
|
||||
sha256: $0.sha256,
|
||||
mimeType: $0.mimeType,
|
||||
width: $0.width,
|
||||
height: $0.height
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -518,6 +581,13 @@ private func sampleMp3Data(seed: String) -> Data {
|
||||
] + Array(seed.utf8))
|
||||
}
|
||||
|
||||
private func sampleArtworkData() -> Data {
|
||||
Data(
|
||||
base64Encoded:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
|
||||
)!
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user