Implement artwork download and cache

This commit is contained in:
diyaa
2026-05-30 09:43:14 +02:00
parent 8caf29f186
commit 7b1952794c
23 changed files with 1261 additions and 66 deletions
@@ -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()
}
@@ -31,7 +31,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
@@ -76,7 +77,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
@@ -109,7 +111,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: store
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
await XCTAssertThrowsErrorAsync {
@@ -132,7 +135,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
let track = RemoteTrack(
trackId: "track-123",
@@ -166,7 +170,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: InMemoryOfflineAudioFileStore()
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
let track = RemoteTrack(
trackId: "track-123",
@@ -222,7 +227,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory)
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory),
artworkStore: InMemoryArtworkStore()
)
let originalState = try await firstService.downloadTrack(track, deviceId: "device-123")
@@ -238,7 +244,8 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: relaunchedAudioStore
audioFileStore: relaunchedAudioStore,
artworkStore: InMemoryArtworkStore()
)
let restoredStates = try await relaunchedService.loadDownloadStates()
@@ -254,6 +261,58 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
XCTAssertEqual(restoredBytes, audioData)
}
func testSyncCachesArtworkIndependentlyFromAudioDownloads() async throws {
let artwork = RemoteArtwork(
artworkId: "artwork-123",
sha256: String(repeating: "d", count: 64),
mimeType: "image/png",
width: 1,
height: 1
)
let artworkStore = InMemoryArtworkStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: [
RemoteTrackDTO(
trackId: "track-123",
title: "Remote Title",
artist: "Remote Artist",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z",
artwork: RemoteArtworkDTO(
artworkId: artwork.artworkId,
sha256: artwork.sha256,
mimeType: artwork.mimeType,
width: artwork.width,
height: artwork.height
)
),
]
),
artworkData: sampleArtworkData()
),
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: artworkStore
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedArtworkPath = await artworkStore.cachedFilePath(for: artwork)
let cachedArtworkBytes = try await artworkStore.readArtwork(
at: try XCTUnwrap(cachedArtworkPath)
)
XCTAssertEqual(tracks.first?.artwork, artwork)
XCTAssertEqual(cachedArtworkBytes, sampleArtworkData())
}
}
private struct MockVelodyAPIClient: VelodyAPIClient {
@@ -261,17 +320,23 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
let remoteLibraryError: VelodyAPIError?
let audioAssetData: Data?
let downloadError: VelodyAPIError?
let artworkData: Data?
let artworkDownloadError: VelodyAPIError?
init(
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
remoteLibraryError: VelodyAPIError? = nil,
audioAssetData: Data? = nil,
downloadError: VelodyAPIError? = nil
downloadError: VelodyAPIError? = nil,
artworkData: Data? = nil,
artworkDownloadError: VelodyAPIError? = nil
) {
self.remoteLibraryResponse = remoteLibraryResponse
self.remoteLibraryError = remoteLibraryError
self.audioAssetData = audioAssetData
self.downloadError = downloadError
self.artworkData = artworkData
self.artworkDownloadError = artworkDownloadError
}
func registerDevice(
@@ -331,6 +396,20 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
return audioAssetData ?? Data()
}
func downloadArtwork(
artworkId: String,
deviceId: String
) async throws -> Data {
_ = artworkId
_ = deviceId
if let artworkDownloadError {
throw artworkDownloadError
}
return artworkData ?? Data()
}
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@@ -385,6 +464,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()
}