Implement artwork download and cache
This commit is contained in:
@@ -12,6 +12,7 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
|
||||
public var localFilePath: String
|
||||
public var downloadedAt: Date?
|
||||
public var isFileAvailable: Bool
|
||||
public var localArtworkFilePath: String?
|
||||
|
||||
public init(
|
||||
remoteTrackId: String,
|
||||
@@ -22,7 +23,8 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
|
||||
sha256: String,
|
||||
localFilePath: String,
|
||||
downloadedAt: Date?,
|
||||
isFileAvailable: Bool
|
||||
isFileAvailable: Bool,
|
||||
localArtworkFilePath: String? = nil
|
||||
) {
|
||||
self.remoteTrackId = remoteTrackId
|
||||
self.assetId = assetId
|
||||
@@ -33,6 +35,7 @@ public struct OfflineLibraryTrack: Identifiable, Hashable, Sendable {
|
||||
self.localFilePath = localFilePath
|
||||
self.downloadedAt = downloadedAt
|
||||
self.isFileAvailable = isFileAvailable
|
||||
self.localArtworkFilePath = localArtworkFilePath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +56,7 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
|
||||
public var isFileAvailable: Bool
|
||||
public var status: OfflineLibraryRemoteTrackStatus
|
||||
public var lastDownloadError: String?
|
||||
public var localArtworkFilePath: String?
|
||||
|
||||
public init(
|
||||
remoteTrack: RemoteTrack,
|
||||
@@ -60,7 +64,8 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
|
||||
downloadedAt: Date?,
|
||||
isFileAvailable: Bool,
|
||||
status: OfflineLibraryRemoteTrackStatus,
|
||||
lastDownloadError: String?
|
||||
lastDownloadError: String?,
|
||||
localArtworkFilePath: String? = nil
|
||||
) {
|
||||
self.remoteTrack = remoteTrack
|
||||
self.localFilePath = localFilePath
|
||||
@@ -68,6 +73,7 @@ public struct OfflineLibraryRemoteTrack: Identifiable, Hashable, Sendable {
|
||||
self.isFileAvailable = isFileAvailable
|
||||
self.status = status
|
||||
self.lastDownloadError = lastDownloadError
|
||||
self.localArtworkFilePath = localArtworkFilePath
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
public struct RemoteArtwork: Codable, Hashable, Sendable {
|
||||
public var artworkId: String
|
||||
public var sha256: String
|
||||
public var mimeType: String
|
||||
public var width: Int?
|
||||
public var height: Int?
|
||||
|
||||
public init(
|
||||
artworkId: String,
|
||||
sha256: String,
|
||||
mimeType: String,
|
||||
width: Int? = nil,
|
||||
height: Int? = nil
|
||||
) {
|
||||
self.artworkId = artworkId
|
||||
self.sha256 = sha256
|
||||
self.mimeType = mimeType
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
|
||||
public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
|
||||
public var id: String { trackId }
|
||||
|
||||
@@ -11,6 +33,7 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
|
||||
public var assetId: String
|
||||
public var createdAt: String
|
||||
public var updatedAt: String
|
||||
public var artwork: RemoteArtwork?
|
||||
|
||||
public init(
|
||||
trackId: String,
|
||||
@@ -20,7 +43,8 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
|
||||
sha256: String,
|
||||
assetId: String,
|
||||
createdAt: String,
|
||||
updatedAt: String
|
||||
updatedAt: String,
|
||||
artwork: RemoteArtwork? = nil
|
||||
) {
|
||||
self.trackId = trackId
|
||||
self.title = title
|
||||
@@ -30,5 +54,6 @@ public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
|
||||
self.assetId = assetId
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.artwork = artwork
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
import Foundation
|
||||
import VelodyDomain
|
||||
|
||||
public struct RemoteArtworkDTO: Codable, Hashable, Sendable {
|
||||
public var artworkId: String
|
||||
public var sha256: String
|
||||
public var mimeType: String
|
||||
public var width: Int?
|
||||
public var height: Int?
|
||||
|
||||
public init(
|
||||
artworkId: String,
|
||||
sha256: String,
|
||||
mimeType: String,
|
||||
width: Int? = nil,
|
||||
height: Int? = nil
|
||||
) {
|
||||
self.artworkId = artworkId
|
||||
self.sha256 = sha256
|
||||
self.mimeType = mimeType
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
public var remoteArtwork: RemoteArtwork {
|
||||
RemoteArtwork(
|
||||
artworkId: artworkId,
|
||||
sha256: sha256,
|
||||
mimeType: mimeType,
|
||||
width: width,
|
||||
height: height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
||||
public var trackId: String
|
||||
public var title: String
|
||||
@@ -10,6 +42,7 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
||||
public var assetId: String
|
||||
public var createdAt: String
|
||||
public var updatedAt: String
|
||||
public var artwork: RemoteArtworkDTO?
|
||||
|
||||
public init(
|
||||
trackId: String,
|
||||
@@ -19,7 +52,8 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
||||
sha256: String,
|
||||
assetId: String,
|
||||
createdAt: String,
|
||||
updatedAt: String
|
||||
updatedAt: String,
|
||||
artwork: RemoteArtworkDTO? = nil
|
||||
) {
|
||||
self.trackId = trackId
|
||||
self.title = title
|
||||
@@ -29,6 +63,7 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
||||
self.assetId = assetId
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.artwork = artwork
|
||||
}
|
||||
|
||||
public var remoteTrack: RemoteTrack {
|
||||
@@ -40,7 +75,8 @@ public struct RemoteTrackDTO: Codable, Hashable, Sendable {
|
||||
sha256: sha256,
|
||||
assetId: assetId,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
updatedAt: updatedAt,
|
||||
artwork: artwork?.remoteArtwork
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ public protocol VelodyAPIClient: Sendable {
|
||||
deviceId: String
|
||||
) async throws -> Data
|
||||
|
||||
func downloadArtwork(
|
||||
artworkId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data
|
||||
|
||||
func prepareUpload(
|
||||
_ payload: UploadPrepareRequest
|
||||
) async throws -> UploadPrepareResponse
|
||||
@@ -145,6 +150,23 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
return try await executeData(request)
|
||||
}
|
||||
|
||||
public func downloadArtwork(
|
||||
artworkId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data {
|
||||
let request = try buildRequest(
|
||||
method: "GET",
|
||||
pathComponents: ["api", "v1", "artwork", artworkId, "download"],
|
||||
queryItems: [
|
||||
URLQueryItem(name: "deviceId", value: deviceId),
|
||||
],
|
||||
bodyData: nil,
|
||||
acceptType: "image/*"
|
||||
)
|
||||
|
||||
return try await executeData(request)
|
||||
}
|
||||
|
||||
public func prepareUpload(
|
||||
_ payload: UploadPrepareRequest
|
||||
) async throws -> UploadPrepareResponse {
|
||||
@@ -450,6 +472,18 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
|
||||
])
|
||||
}
|
||||
|
||||
public func downloadArtwork(
|
||||
artworkId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data {
|
||||
_ = artworkId
|
||||
_ = deviceId
|
||||
|
||||
return Data([
|
||||
0x89, 0x50, 0x4E, 0x47,
|
||||
])
|
||||
}
|
||||
|
||||
public func prepareUpload(
|
||||
_ payload: UploadPrepareRequest
|
||||
) async throws -> UploadPrepareResponse {
|
||||
|
||||
+28
-2
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
import VelodyDomain
|
||||
@testable import VelodyNetworking
|
||||
|
||||
final class RemoteLibraryDTOTests: XCTestCase {
|
||||
@@ -12,7 +13,14 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
updatedAt: "2026-05-29T08:05:00.000Z",
|
||||
artwork: RemoteArtworkDTO(
|
||||
artworkId: "artwork-789",
|
||||
sha256: String(repeating: "b", count: 64),
|
||||
mimeType: "image/png",
|
||||
width: 512,
|
||||
height: 512
|
||||
)
|
||||
)
|
||||
|
||||
let track = dto.remoteTrack
|
||||
@@ -23,6 +31,16 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
||||
XCTAssertEqual(track.durationSeconds, 245)
|
||||
XCTAssertEqual(track.sha256, String(repeating: "a", count: 64))
|
||||
XCTAssertEqual(track.assetId, "asset-456")
|
||||
XCTAssertEqual(
|
||||
track.artwork,
|
||||
RemoteArtwork(
|
||||
artworkId: "artwork-789",
|
||||
sha256: String(repeating: "b", count: 64),
|
||||
mimeType: "image/png",
|
||||
width: 512,
|
||||
height: 512
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testRemoteLibraryResponseDTODecodesFromAPIResponse() throws {
|
||||
@@ -38,7 +56,14 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
||||
"sha256": "\(String(repeating: "a", count: 64))",
|
||||
"assetId": "asset-456",
|
||||
"createdAt": "2026-05-29T08:00:00.000Z",
|
||||
"updatedAt": "2026-05-29T08:05:00.000Z"
|
||||
"updatedAt": "2026-05-29T08:05:00.000Z",
|
||||
"artwork": {
|
||||
"artworkId": "artwork-789",
|
||||
"sha256": "\(String(repeating: "b", count: 64))",
|
||||
"mimeType": "image/png",
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -50,5 +75,6 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
||||
XCTAssertEqual(decoded.tracks.count, 1)
|
||||
XCTAssertEqual(decoded.tracks.first?.trackId, "track-123")
|
||||
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
|
||||
XCTAssertEqual(decoded.tracks.first?.artwork?.artworkId, "artwork-789")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import VelodyDomain
|
||||
|
||||
public enum ArtworkStoreError: LocalizedError, Equatable, Sendable {
|
||||
case emptyArtworkData
|
||||
case missingLocalFile(path: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyArtworkData:
|
||||
return "The downloaded artwork file was empty."
|
||||
case let .missingLocalFile(path):
|
||||
return "The local artwork file is missing: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol ArtworkStore: Actor {
|
||||
func saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String
|
||||
func readArtwork(at localFilePath: String) async throws -> Data
|
||||
func fileExists(at localFilePath: String) async -> Bool
|
||||
func cachedFilePath(for artwork: RemoteArtwork) async -> String?
|
||||
}
|
||||
|
||||
public actor FileArtworkStore: ArtworkStore {
|
||||
private let baseDirectoryURL: URL
|
||||
private let fileManager: FileManager
|
||||
|
||||
public init(
|
||||
baseDirectoryURL: URL? = nil,
|
||||
fileManager: FileManager = .default
|
||||
) throws {
|
||||
self.fileManager = fileManager
|
||||
if let baseDirectoryURL {
|
||||
self.baseDirectoryURL = baseDirectoryURL
|
||||
} else {
|
||||
self.baseDirectoryURL = try Self.defaultBaseDirectoryURL(fileManager: fileManager)
|
||||
}
|
||||
}
|
||||
|
||||
public func saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String {
|
||||
guard !data.isEmpty else {
|
||||
throw ArtworkStoreError.emptyArtworkData
|
||||
}
|
||||
|
||||
try fileManager.createDirectory(
|
||||
at: baseDirectoryURL,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let fileURL = localFileURL(for: artwork)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
|
||||
let storedData = try Data(contentsOf: fileURL)
|
||||
guard !storedData.isEmpty else {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
throw ArtworkStoreError.emptyArtworkData
|
||||
}
|
||||
|
||||
return fileURL.standardizedFileURL.path
|
||||
}
|
||||
|
||||
public func readArtwork(at localFilePath: String) async throws -> Data {
|
||||
let standardizedPath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
|
||||
guard fileManager.fileExists(atPath: standardizedPath) else {
|
||||
throw ArtworkStoreError.missingLocalFile(path: localFilePath)
|
||||
}
|
||||
|
||||
return try Data(contentsOf: URL(fileURLWithPath: standardizedPath))
|
||||
}
|
||||
|
||||
public func fileExists(at localFilePath: String) async -> Bool {
|
||||
let standardizedPath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
|
||||
return fileManager.fileExists(atPath: standardizedPath)
|
||||
}
|
||||
|
||||
public func cachedFilePath(for artwork: RemoteArtwork) async -> String? {
|
||||
let expectedFileURL = localFileURL(for: artwork).standardizedFileURL
|
||||
if fileManager.fileExists(atPath: expectedFileURL.path) {
|
||||
return expectedFileURL.path
|
||||
}
|
||||
|
||||
guard let contents = try? fileManager.contentsOfDirectory(
|
||||
at: baseDirectoryURL,
|
||||
includingPropertiesForKeys: nil
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fallbackPrefix = "\(artwork.artworkId)."
|
||||
return contents
|
||||
.first(where: {
|
||||
$0.lastPathComponent.hasPrefix(fallbackPrefix) &&
|
||||
fileManager.fileExists(atPath: $0.path)
|
||||
})?
|
||||
.standardizedFileURL
|
||||
.path
|
||||
}
|
||||
|
||||
private static func defaultBaseDirectoryURL(fileManager: FileManager) throws -> URL {
|
||||
guard let applicationSupportURL = fileManager.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
|
||||
return applicationSupportURL
|
||||
.appendingPathComponent("Velody", isDirectory: true)
|
||||
.appendingPathComponent("artwork", isDirectory: true)
|
||||
}
|
||||
|
||||
private func localFileURL(for artwork: RemoteArtwork) -> URL {
|
||||
baseDirectoryURL.appendingPathComponent(
|
||||
"\(artwork.artworkId).\(Self.fileExtension(for: artwork.mimeType))"
|
||||
)
|
||||
}
|
||||
|
||||
private static func fileExtension(for mimeType: String) -> String {
|
||||
switch mimeType.lowercased() {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/heic":
|
||||
return "heic"
|
||||
case "image/heif":
|
||||
return "heif"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
default:
|
||||
return "img"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryArtworkStore: ArtworkStore {
|
||||
private var files: [String: Data]
|
||||
|
||||
public init(files: [String: Data] = [:]) {
|
||||
self.files = files
|
||||
}
|
||||
|
||||
public func saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String {
|
||||
guard !data.isEmpty else {
|
||||
throw ArtworkStoreError.emptyArtworkData
|
||||
}
|
||||
|
||||
let localFilePath = Self.localFilePath(for: artwork)
|
||||
files[localFilePath] = data
|
||||
return localFilePath
|
||||
}
|
||||
|
||||
public func readArtwork(at localFilePath: String) async throws -> Data {
|
||||
guard let data = files[localFilePath] else {
|
||||
throw ArtworkStoreError.missingLocalFile(path: localFilePath)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
public func fileExists(at localFilePath: String) async -> Bool {
|
||||
files[localFilePath] != nil
|
||||
}
|
||||
|
||||
public func cachedFilePath(for artwork: RemoteArtwork) async -> String? {
|
||||
let expectedFilePath = Self.localFilePath(for: artwork)
|
||||
if files[expectedFilePath] != nil {
|
||||
return expectedFilePath
|
||||
}
|
||||
|
||||
let fallbackPrefix = "/in-memory/\(artwork.artworkId)."
|
||||
return files.keys.first(where: { $0.hasPrefix(fallbackPrefix) })
|
||||
}
|
||||
|
||||
private static func localFilePath(for artwork: RemoteArtwork) -> String {
|
||||
let fileExtension: String
|
||||
|
||||
switch artwork.mimeType.lowercased() {
|
||||
case "image/jpeg", "image/jpg":
|
||||
fileExtension = "jpg"
|
||||
case "image/png":
|
||||
fileExtension = "png"
|
||||
case "image/webp":
|
||||
fileExtension = "webp"
|
||||
case "image/heic":
|
||||
fileExtension = "heic"
|
||||
case "image/heif":
|
||||
fileExtension = "heif"
|
||||
case "image/gif":
|
||||
fileExtension = "gif"
|
||||
default:
|
||||
fileExtension = "img"
|
||||
}
|
||||
|
||||
return "/in-memory/\(artwork.artworkId).\(fileExtension)"
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
import VelodyDomain
|
||||
@testable import VelodyPersistence
|
||||
|
||||
final class ArtworkStoreTests: XCTestCase {
|
||||
func testFileArtworkStoreWritesAndReadsArtworkData() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let store = try FileArtworkStore(baseDirectoryURL: tempDirectory)
|
||||
let artwork = RemoteArtwork(
|
||||
artworkId: "artwork-123",
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
let bytes = sampleArtworkData()
|
||||
|
||||
let localFilePath = try await store.saveArtwork(bytes, artwork: artwork)
|
||||
let storedBytes = try await store.readArtwork(at: localFilePath)
|
||||
let fileExists = await store.fileExists(at: localFilePath)
|
||||
let cachedFilePath = await store.cachedFilePath(for: artwork)
|
||||
|
||||
XCTAssertEqual(storedBytes, bytes)
|
||||
XCTAssertTrue(fileExists)
|
||||
XCTAssertEqual(cachedFilePath, localFilePath)
|
||||
}
|
||||
|
||||
func testFileArtworkStoreRejectsEmptyArtworkData() async throws {
|
||||
let store = try FileArtworkStore(
|
||||
baseDirectoryURL: FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
_ = try await store.saveArtwork(
|
||||
Data(),
|
||||
artwork: RemoteArtwork(
|
||||
artworkId: "artwork-123",
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
mimeType: "image/png"
|
||||
)
|
||||
)
|
||||
} assertion: { error in
|
||||
XCTAssertEqual(error as? ArtworkStoreError, .emptyArtworkData)
|
||||
}
|
||||
}
|
||||
|
||||
func testFileArtworkStorePersistsArtworkAcrossInstances() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
let artwork = RemoteArtwork(
|
||||
artworkId: "artwork-123",
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let firstStore = try FileArtworkStore(baseDirectoryURL: tempDirectory)
|
||||
let firstLocalFilePath = try await firstStore.saveArtwork(
|
||||
sampleArtworkData(),
|
||||
artwork: artwork
|
||||
)
|
||||
|
||||
let secondStore = try FileArtworkStore(baseDirectoryURL: tempDirectory)
|
||||
let restoredLocalFilePath = await secondStore.cachedFilePath(for: artwork)
|
||||
|
||||
XCTAssertEqual(restoredLocalFilePath, firstLocalFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
private func sampleArtworkData() -> Data {
|
||||
Data(
|
||||
base64Encoded:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
|
||||
)!
|
||||
}
|
||||
|
||||
private func XCTAssertThrowsErrorAsync(
|
||||
_ expression: @escaping () async throws -> Void,
|
||||
assertion: (Error) -> Void,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
do {
|
||||
try await expression()
|
||||
XCTFail("Expected expression to throw an error.", file: file, line: line)
|
||||
} catch {
|
||||
assertion(error)
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,16 @@ import VelodyPersistence
|
||||
public actor OfflineLibraryService {
|
||||
private let syncService: RemoteLibrarySyncService
|
||||
private let audioFileStore: any OfflineAudioFileStore
|
||||
private let artworkStore: any ArtworkStore
|
||||
|
||||
public init(
|
||||
syncService: RemoteLibrarySyncService,
|
||||
audioFileStore: any OfflineAudioFileStore
|
||||
audioFileStore: any OfflineAudioFileStore,
|
||||
artworkStore: any ArtworkStore
|
||||
) {
|
||||
self.syncService = syncService
|
||||
self.audioFileStore = audioFileStore
|
||||
self.artworkStore = artworkStore
|
||||
}
|
||||
|
||||
public func loadSnapshot() async throws -> OfflineLibrarySnapshot {
|
||||
@@ -37,6 +40,10 @@ public actor OfflineLibraryService {
|
||||
for: downloadState,
|
||||
status: status
|
||||
)
|
||||
let localArtworkFilePath = await Self.localArtworkFilePath(
|
||||
for: remoteTrack.artwork,
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
|
||||
remoteLibraryTracks.append(
|
||||
OfflineLibraryRemoteTrack(
|
||||
@@ -45,7 +52,8 @@ public actor OfflineLibraryService {
|
||||
downloadedAt: downloadedAt,
|
||||
isFileAvailable: isFileAvailable,
|
||||
status: status,
|
||||
lastDownloadError: lastDownloadError
|
||||
lastDownloadError: lastDownloadError,
|
||||
localArtworkFilePath: localArtworkFilePath
|
||||
)
|
||||
)
|
||||
|
||||
@@ -63,7 +71,8 @@ public actor OfflineLibraryService {
|
||||
sha256: remoteTrack.sha256,
|
||||
localFilePath: localFilePath,
|
||||
downloadedAt: downloadedAt,
|
||||
isFileAvailable: true
|
||||
isFileAvailable: true,
|
||||
localArtworkFilePath: localArtworkFilePath
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -118,4 +127,15 @@ public actor OfflineLibraryService {
|
||||
|
||||
return downloadState?.lastDownloadError
|
||||
}
|
||||
|
||||
private static func localArtworkFilePath(
|
||||
for artwork: RemoteArtwork?,
|
||||
artworkStore: any ArtworkStore
|
||||
) async -> String? {
|
||||
guard let artwork else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return await artworkStore.cachedFilePath(for: artwork)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -38,4 +39,11 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
||||
) 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,18 @@ public actor RemoteLibrarySyncService {
|
||||
private let repository: any RemoteLibraryRepository
|
||||
private let downloadStateStore: any RemoteTrackDownloadStateStore
|
||||
private let audioFileStore: any OfflineAudioFileStore
|
||||
private let artworkStore: any ArtworkStore
|
||||
|
||||
public init(
|
||||
repository: any RemoteLibraryRepository,
|
||||
downloadStateStore: any RemoteTrackDownloadStateStore,
|
||||
audioFileStore: any OfflineAudioFileStore
|
||||
audioFileStore: any OfflineAudioFileStore,
|
||||
artworkStore: any ArtworkStore
|
||||
) {
|
||||
self.repository = repository
|
||||
self.downloadStateStore = downloadStateStore
|
||||
self.audioFileStore = audioFileStore
|
||||
self.artworkStore = artworkStore
|
||||
}
|
||||
|
||||
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
||||
@@ -29,6 +32,7 @@ public actor RemoteLibrarySyncService {
|
||||
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
|
||||
let tracks = try await repository.syncRemoteTracks(deviceId: deviceId)
|
||||
try await ensureDownloadStates(for: tracks)
|
||||
await cacheArtwork(for: tracks, deviceId: deviceId)
|
||||
return tracks
|
||||
}
|
||||
|
||||
@@ -181,4 +185,29 @@ public actor RemoteLibrarySyncService {
|
||||
|
||||
return reconciledStates
|
||||
}
|
||||
|
||||
private func cacheArtwork(
|
||||
for tracks: [RemoteTrack],
|
||||
deviceId: String
|
||||
) async {
|
||||
for track in tracks {
|
||||
guard let artwork = track.artwork else {
|
||||
continue
|
||||
}
|
||||
|
||||
if await artworkStore.cachedFilePath(for: artwork) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let artworkData = try await repository.downloadArtwork(
|
||||
artworkId: artwork.artworkId,
|
||||
deviceId: deviceId
|
||||
)
|
||||
_ = try await artworkStore.saveArtwork(artworkData, artwork: artwork)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+94
-8
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user