Implement Milestone 7.2 offline audio downloads

This commit is contained in:
diyaa
2026-05-30 07:11:20 +02:00
parent 6e73c1878e
commit 56f030e651
19 changed files with 1822 additions and 31 deletions
@@ -0,0 +1,37 @@
import Foundation
public enum RemoteTrackDownloadStatus: String, Codable, Hashable, Sendable, CaseIterable {
case notDownloaded
case downloading
case downloaded
case failed
}
public struct RemoteTrackDownloadState: Codable, Hashable, Sendable {
public var remoteTrackId: String
public var assetId: String
public var localFilePath: String
public var downloadedAt: Date?
public var downloadStatus: RemoteTrackDownloadStatus
public var lastDownloadError: String?
public init(
remoteTrackId: String,
assetId: String,
localFilePath: String = "",
downloadedAt: Date? = nil,
downloadStatus: RemoteTrackDownloadStatus,
lastDownloadError: String? = nil
) {
self.remoteTrackId = remoteTrackId
self.assetId = assetId
self.localFilePath = localFilePath
self.downloadedAt = downloadedAt
self.downloadStatus = downloadStatus
self.lastDownloadError = lastDownloadError
}
public var hasLocalFile: Bool {
!localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
@@ -43,6 +43,11 @@ public protocol VelodyAPIClient: Sendable {
deviceId: String
) async throws -> RemoteLibraryResponseDTO
func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse
@@ -123,6 +128,23 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
)
}
public func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
let request = try buildRequest(
method: "GET",
pathComponents: ["api", "v1", "assets", assetId, "download"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
bodyData: nil,
acceptType: "audio/mpeg"
)
return try await executeData(request)
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@@ -236,7 +258,8 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
pathComponents: [String],
queryItems: [URLQueryItem],
bodyData: Data?,
contentType: String? = nil
contentType: String? = nil,
acceptType: String = "application/json"
) throws -> URLRequest {
guard let url = endpointURL(
pathComponents: pathComponents,
@@ -247,7 +270,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(acceptType, forHTTPHeaderField: "Accept")
if let bodyData {
request.httpBody = bodyData
@@ -280,6 +303,20 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
)
}
private func executeData(_ request: URLRequest) async throws -> Data {
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
try validate(response: response, data: data)
return data
}
private func decodeResponse<Response: Decodable>(
data: Data,
response: URLResponse,
@@ -401,6 +438,18 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
)
}
public func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
_ = assetId
_ = deviceId
return Data([
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
])
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@@ -0,0 +1,209 @@
import CryptoKit
import Foundation
public enum OfflineAudioFileStoreError: LocalizedError, Equatable, Sendable {
case emptyAudioData
case sha256Mismatch(expected: String, actual: String)
case missingLocalFile(path: String)
public var errorDescription: String? {
switch self {
case .emptyAudioData:
return "The downloaded audio file was empty."
case let .sha256Mismatch(expected, actual):
return "The downloaded audio file hash did not match. Expected \(expected), received \(actual)."
case let .missingLocalFile(path):
return "The local audio file is missing: \(path)"
}
}
}
public protocol OfflineAudioFileStore: Actor {
func saveAudioFile(
_ data: Data,
assetId: String,
sha256: String?
) async throws -> String
func readAudioFile(at localFilePath: String) async throws -> Data
func fileExists(at localFilePath: String) async -> Bool
func resolveLocalFilePath(
persistedLocalFilePath: String,
assetId: String
) async -> String?
}
public actor FileOfflineAudioFileStore: OfflineAudioFileStore {
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 saveAudioFile(
_ data: Data,
assetId: String,
sha256: String?
) async throws -> String {
guard !data.isEmpty else {
throw OfflineAudioFileStoreError.emptyAudioData
}
try fileManager.createDirectory(
at: baseDirectoryURL,
withIntermediateDirectories: true
)
let fileURL = localFileURL(for: assetId)
try data.write(to: fileURL, options: .atomic)
let storedData = try Data(contentsOf: fileURL)
guard !storedData.isEmpty else {
try? fileManager.removeItem(at: fileURL)
throw OfflineAudioFileStoreError.emptyAudioData
}
if let sha256 {
let actualHash = Self.sha256Hex(for: storedData)
if actualHash != sha256 {
try? fileManager.removeItem(at: fileURL)
throw OfflineAudioFileStoreError.sha256Mismatch(
expected: sha256,
actual: actualHash
)
}
}
return fileURL.standardizedFileURL.path
}
public func readAudioFile(at localFilePath: String) async throws -> Data {
guard let resolvedLocalFilePath = await resolveLocalFilePath(
persistedLocalFilePath: localFilePath,
assetId: URL(fileURLWithPath: localFilePath).deletingPathExtension().lastPathComponent
) else {
throw OfflineAudioFileStoreError.missingLocalFile(path: localFilePath)
}
return try Data(contentsOf: URL(fileURLWithPath: resolvedLocalFilePath))
}
public func fileExists(at localFilePath: String) async -> Bool {
let resolvedLocalFilePath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
return fileManager.fileExists(atPath: resolvedLocalFilePath)
}
public func resolveLocalFilePath(
persistedLocalFilePath: String,
assetId: String
) async -> String? {
let trimmedPersistedPath = persistedLocalFilePath
.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedPersistedPath.isEmpty {
let persistedURL = URL(fileURLWithPath: trimmedPersistedPath).standardizedFileURL
if fileManager.fileExists(atPath: persistedURL.path) {
return persistedURL.path
}
}
let currentFileURL = localFileURL(for: assetId).standardizedFileURL
guard fileManager.fileExists(atPath: currentFileURL.path) else {
return nil
}
return currentFileURL.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("audio", isDirectory: true)
}
private func localFileURL(for assetId: String) -> URL {
baseDirectoryURL.appendingPathComponent("\(assetId).mp3")
}
private static func sha256Hex(for data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
}
public actor InMemoryOfflineAudioFileStore: OfflineAudioFileStore {
private var files: [String: Data]
public init(files: [String: Data] = [:]) {
self.files = files
}
public func saveAudioFile(
_ data: Data,
assetId: String,
sha256: String?
) async throws -> String {
guard !data.isEmpty else {
throw OfflineAudioFileStoreError.emptyAudioData
}
if let sha256 {
let actualHash = SHA256.hash(data: data)
.map { String(format: "%02x", $0) }
.joined()
if actualHash != sha256 {
throw OfflineAudioFileStoreError.sha256Mismatch(
expected: sha256,
actual: actualHash
)
}
}
let localFilePath = "/in-memory/\(assetId).mp3"
files[localFilePath] = data
return localFilePath
}
public func readAudioFile(at localFilePath: String) async throws -> Data {
guard let data = files[localFilePath] else {
throw OfflineAudioFileStoreError.missingLocalFile(path: localFilePath)
}
return data
}
public func fileExists(at localFilePath: String) async -> Bool {
files[localFilePath] != nil
}
public func resolveLocalFilePath(
persistedLocalFilePath: String,
assetId: String
) async -> String? {
let trimmedPersistedPath = persistedLocalFilePath
.trimmingCharacters(in: .whitespacesAndNewlines)
if files[trimmedPersistedPath] != nil {
return trimmedPersistedPath
}
let fallbackLocalFilePath = "/in-memory/\(assetId).mp3"
guard files[fallbackLocalFilePath] != nil else {
return nil
}
return fallbackLocalFilePath
}
}
@@ -0,0 +1,93 @@
import Foundation
import VelodyDomain
public protocol RemoteTrackDownloadStateStore: Actor {
func loadDownloadStates() async throws -> [RemoteTrackDownloadState]
func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws
}
public extension RemoteTrackDownloadStateStore {
func saveDownloadState(_ state: RemoteTrackDownloadState) async throws {
var states = try await loadDownloadStates()
if let existingIndex = states.firstIndex(where: { $0.remoteTrackId == state.remoteTrackId }) {
states[existingIndex] = state
} else {
states.append(state)
}
try await saveDownloadStates(states)
}
}
public actor FileRemoteTrackDownloadStateStore: RemoteTrackDownloadStateStore {
private let fileURL: URL
private let fileManager: FileManager
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(
fileURL: URL? = nil,
fileManager: FileManager = .default
) throws {
self.fileManager = fileManager
if let fileURL {
self.fileURL = fileURL
} else {
self.fileURL = try Self.defaultFileURL(fileManager: fileManager)
}
encoder.dateEncodingStrategy = .iso8601
decoder.dateDecodingStrategy = .iso8601
}
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
guard fileManager.fileExists(atPath: fileURL.path) else {
return []
}
let data = try Data(contentsOf: fileURL)
return try decoder.decode([RemoteTrackDownloadState].self, from: data)
}
public func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws {
try fileManager.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let sortedStates = states.sorted { lhs, rhs in
lhs.remoteTrackId.localizedCaseInsensitiveCompare(rhs.remoteTrackId) == .orderedAscending
}
let data = try encoder.encode(sortedStates)
try data.write(to: fileURL, options: .atomic)
}
private static func defaultFileURL(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("remote-download-states.json")
}
}
public actor InMemoryRemoteTrackDownloadStateStore: RemoteTrackDownloadStateStore {
private var states: [RemoteTrackDownloadState]
public init(states: [RemoteTrackDownloadState] = []) {
self.states = states
}
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
states
}
public func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws {
self.states = states
}
}
@@ -0,0 +1,133 @@
import CryptoKit
import Foundation
import XCTest
@testable import VelodyPersistence
final class OfflineAudioFileStoreTests: XCTestCase {
func testFileOfflineAudioFileStoreWritesAndReadsAudioData() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory)
let bytes = sampleMp3Data(seed: "offline-audio")
let localFilePath = try await store.saveAudioFile(
bytes,
assetId: "asset-123",
sha256: sha256Hex(bytes)
)
let storedBytes = try await store.readAudioFile(at: localFilePath)
let fileExists = await store.fileExists(at: localFilePath)
XCTAssertEqual(storedBytes, bytes)
XCTAssertTrue(fileExists)
}
func testFileOfflineAudioFileStoreRejectsEmptyAudioData() async throws {
let store = try FileOfflineAudioFileStore(
baseDirectoryURL: FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
)
await XCTAssertThrowsErrorAsync {
_ = try await store.saveAudioFile(
Data(),
assetId: "asset-123",
sha256: nil
)
} assertion: { error in
XCTAssertEqual(error as? OfflineAudioFileStoreError, .emptyAudioData)
}
}
func testFileOfflineAudioFileStoreRejectsShaMismatch() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory)
let bytes = sampleMp3Data(seed: "sha-mismatch")
await XCTAssertThrowsErrorAsync {
_ = try await store.saveAudioFile(
bytes,
assetId: "asset-123",
sha256: String(repeating: "f", count: 64)
)
} assertion: { error in
guard case .sha256Mismatch = error as? OfflineAudioFileStoreError else {
return XCTFail("Expected a sha256Mismatch error.")
}
}
}
func testFileOfflineAudioFileStoreResolvesCurrentBaseDirectoryWhenPersistedPathIsStale() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true)
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
let bytes = sampleMp3Data(seed: "path-repair")
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let staleFilePath = firstAudioDirectory
.appendingPathComponent("asset-123.mp3")
.standardizedFileURL
.path
let secondStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
let currentFilePath = try await secondStore.saveAudioFile(
bytes,
assetId: "asset-123",
sha256: sha256Hex(bytes)
)
let resolvedFilePath = await secondStore.resolveLocalFilePath(
persistedLocalFilePath: staleFilePath,
assetId: "asset-123"
)
XCTAssertEqual(resolvedFilePath, currentFilePath)
}
}
private func sampleMp3Data(seed: String) -> Data {
Data([
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
] + Array(seed.utf8))
}
private func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
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)
}
}
@@ -0,0 +1,38 @@
import Foundation
import XCTest
import VelodyDomain
@testable import VelodyPersistence
final class RemoteTrackDownloadStateStoreTests: XCTestCase {
func testFileDownloadStateStorePersistsAcrossInstances() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let fileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstStore = try FileRemoteTrackDownloadStateStore(fileURL: fileURL)
let states = [
RemoteTrackDownloadState(
remoteTrackId: "track-123",
assetId: "asset-456",
localFilePath: "/tmp/asset-456.mp3",
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloaded,
lastDownloadError: nil
),
]
try await firstStore.saveDownloadStates(states)
let secondStore = try FileRemoteTrackDownloadStateStore(fileURL: fileURL)
let restoredStates = try await secondStore.loadDownloadStates()
XCTAssertEqual(restoredStates, states)
}
}
@@ -6,6 +6,7 @@ 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
}
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
@@ -30,4 +31,11 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
try await store.replaceRemoteTracks(tracks)
return tracks
}
public func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
try await apiClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId)
}
}
@@ -1,18 +1,184 @@
import Foundation
import VelodyDomain
import VelodyPersistence
public actor RemoteLibrarySyncService {
private let repository: any RemoteLibraryRepository
private let downloadStateStore: any RemoteTrackDownloadStateStore
private let audioFileStore: any OfflineAudioFileStore
public init(repository: any RemoteLibraryRepository) {
public init(
repository: any RemoteLibraryRepository,
downloadStateStore: any RemoteTrackDownloadStateStore,
audioFileStore: any OfflineAudioFileStore
) {
self.repository = repository
self.downloadStateStore = downloadStateStore
self.audioFileStore = audioFileStore
}
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
try await repository.loadCachedRemoteTracks()
}
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
let states = try await downloadStateStore.loadDownloadStates()
return try await reconcileDownloadedLocalFilePaths(in: states)
}
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
try await repository.syncRemoteTracks(deviceId: deviceId)
let tracks = try await repository.syncRemoteTracks(deviceId: deviceId)
try await ensureDownloadStates(for: tracks)
return tracks
}
public func downloadTrack(
_ track: RemoteTrack,
deviceId: String
) async throws -> RemoteTrackDownloadState {
let currentState = try await currentDownloadState(for: track)
if currentState.downloadStatus == .downloaded,
currentState.assetId == track.assetId,
currentState.hasLocalFile,
await audioFileStore.fileExists(at: currentState.localFilePath)
{
return currentState
}
let downloadingState = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: currentState.assetId == track.assetId ? currentState.localFilePath : "",
downloadedAt: currentState.assetId == track.assetId ? currentState.downloadedAt : nil,
downloadStatus: .downloading,
lastDownloadError: nil
)
try await downloadStateStore.saveDownloadState(downloadingState)
do {
let audioData = try await repository.downloadAudioAsset(
assetId: track.assetId,
deviceId: deviceId
)
let localFilePath = try await audioFileStore.saveAudioFile(
audioData,
assetId: track.assetId,
sha256: track.sha256
)
let downloadedState = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: localFilePath,
downloadedAt: Date(),
downloadStatus: .downloaded,
lastDownloadError: nil
)
try await downloadStateStore.saveDownloadState(downloadedState)
return downloadedState
} catch {
let failedState = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: "",
downloadedAt: nil,
downloadStatus: .failed,
lastDownloadError: error.localizedDescription
)
try await downloadStateStore.saveDownloadState(failedState)
throw error
}
}
private func ensureDownloadStates(for tracks: [RemoteTrack]) async throws {
guard !tracks.isEmpty else {
return
}
var statesByTrackID = Dictionary(
uniqueKeysWithValues: try await loadDownloadStates()
.map { ($0.remoteTrackId, $0) }
)
var didChange = false
for track in tracks {
guard var existingState = statesByTrackID[track.trackId] else {
statesByTrackID[track.trackId] = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
downloadStatus: .notDownloaded
)
didChange = true
continue
}
if existingState.assetId != track.assetId {
existingState.assetId = track.assetId
existingState.localFilePath = ""
existingState.downloadedAt = nil
existingState.downloadStatus = .notDownloaded
existingState.lastDownloadError = nil
statesByTrackID[track.trackId] = existingState
didChange = true
}
}
if didChange {
try await downloadStateStore.saveDownloadStates(Array(statesByTrackID.values))
}
}
private func currentDownloadState(
for track: RemoteTrack
) async throws -> RemoteTrackDownloadState {
if let existingState = try await loadDownloadStates()
.first(where: { $0.remoteTrackId == track.trackId })
{
if existingState.assetId == track.assetId {
return existingState
}
}
return RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
downloadStatus: .notDownloaded
)
}
private func reconcileDownloadedLocalFilePaths(
in states: [RemoteTrackDownloadState]
) async throws -> [RemoteTrackDownloadState] {
guard !states.isEmpty else {
return states
}
var reconciledStates = states
var didChange = false
for index in reconciledStates.indices {
let state = reconciledStates[index]
guard state.downloadStatus == .downloaded else {
continue
}
guard let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
persistedLocalFilePath: state.localFilePath,
assetId: state.assetId
) else {
continue
}
if state.localFilePath != resolvedLocalFilePath {
reconciledStates[index].localFilePath = resolvedLocalFilePath
didChange = true
}
}
if didChange {
try await downloadStateStore.saveDownloadStates(reconciledStates)
}
return reconciledStates
}
}
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
import XCTest
import VelodyDomain
@@ -8,6 +9,7 @@ import VelodyPersistence
final class RemoteLibrarySyncServiceTests: XCTestCase {
func testSuccessfulSyncPersistsRemoteTracks() async throws {
let store = InMemoryRemoteLibraryStore()
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
@@ -27,15 +29,19 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
)
),
store: store
)
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedTracks = try await service.loadCachedRemoteTracks()
let downloadStates = try await service.loadDownloadStates()
XCTAssertEqual(tracks.count, 1)
XCTAssertEqual(cachedTracks, tracks)
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
XCTAssertEqual(downloadStates.first?.downloadStatus, .notDownloaded)
}
func testEmptyResponseClearsCachedRemoteLibrary() async throws {
@@ -53,20 +59,33 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
),
]
)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(
states: [
RemoteTrackDownloadState(
remoteTrackId: "track-123",
assetId: "asset-123",
downloadStatus: .downloaded
),
]
)
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])
),
store: store
)
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedTracks = try await service.loadCachedRemoteTracks()
let downloadStates = try await service.loadDownloadStates()
XCTAssertEqual(tracks, [])
XCTAssertEqual(cachedTracks, [])
XCTAssertEqual(downloadStates.count, 1)
}
func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws {
@@ -81,13 +100,16 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
updatedAt: "2026-05-29T08:05:00.000Z"
)
let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
),
store: store
)
),
downloadStateStore: downloadStateStore,
audioFileStore: InMemoryOfflineAudioFileStore()
)
await XCTAssertThrowsErrorAsync {
@@ -97,18 +119,159 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
let cachedTracks = try await service.loadCachedRemoteTracks()
XCTAssertEqual(cachedTracks, [cachedTrack])
}
func testDownloadTrackPersistsDownloadedStateAndFile() async throws {
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let audioFileStore = InMemoryOfflineAudioFileStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
audioAssetData: sampleMp3Data(seed: "download-success")
),
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
)
let track = RemoteTrack(
trackId: "track-123",
title: "Remote Title",
artist: "Remote Artist",
durationSeconds: 245,
sha256: sha256Hex(sampleMp3Data(seed: "download-success")),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
)
let state = try await service.downloadTrack(track, deviceId: "device-123")
let storedStates = try await service.loadDownloadStates()
let fileExists = await audioFileStore.fileExists(at: state.localFilePath)
XCTAssertEqual(state.downloadStatus, .downloaded)
XCTAssertEqual(state.assetId, "asset-456")
XCTAssertFalse(state.localFilePath.isEmpty)
XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded)
XCTAssertTrue(fileExists)
}
func testDownloadTrackPersistsFailureState() async throws {
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
),
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: InMemoryOfflineAudioFileStore()
)
let track = RemoteTrack(
trackId: "track-123",
title: "Remote Title",
artist: "Remote Artist",
durationSeconds: 245,
sha256: sha256Hex(sampleMp3Data(seed: "download-failure")),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
)
await XCTAssertThrowsErrorAsync {
_ = try await service.downloadTrack(track, deviceId: "device-123")
}
let storedStates = try await service.loadDownloadStates()
XCTAssertEqual(storedStates.first?.downloadStatus, .failed)
XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123")
}
func testLoadDownloadStatesRepairsStaleLocalFilePathAfterStoreRecreation() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true)
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
let audioData = sampleMp3Data(seed: "relaunch-repair")
let track = RemoteTrack(
trackId: "track-123",
title: "1 Mai 2026",
artist: "Remote Artist",
durationSeconds: 245,
sha256: sha256Hex(audioData),
assetId: "asset-456",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
audioAssetData: audioData
),
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory)
)
let originalState = try await firstService.downloadTrack(track, deviceId: "device-123")
let originalFileURL = URL(fileURLWithPath: originalState.localFilePath)
let recreatedStoreFileURL = secondAudioDirectory.appendingPathComponent("asset-456.mp3")
try fileManager.createDirectory(at: secondAudioDirectory, withIntermediateDirectories: true)
try fileManager.moveItem(at: originalFileURL, to: recreatedStoreFileURL)
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
let relaunchedService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])),
store: InMemoryRemoteLibraryStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: relaunchedAudioStore
)
let restoredStates = try await relaunchedService.loadDownloadStates()
let restoredState = try XCTUnwrap(restoredStates.first)
let restoredBytes = try await relaunchedAudioStore.readAudioFile(at: restoredState.localFilePath)
let persistedRestoredState = try await FileRemoteTrackDownloadStateStore(fileURL: stateFileURL)
.loadDownloadStates()
.first
XCTAssertEqual(restoredState.downloadStatus, .downloaded)
XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
XCTAssertEqual(restoredBytes, audioData)
}
}
private struct MockVelodyAPIClient: VelodyAPIClient {
let remoteLibraryResponse: RemoteLibraryResponseDTO?
let remoteLibraryError: VelodyAPIError?
let audioAssetData: Data?
let downloadError: VelodyAPIError?
init(
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
remoteLibraryError: VelodyAPIError? = nil
remoteLibraryError: VelodyAPIError? = nil,
audioAssetData: Data? = nil,
downloadError: VelodyAPIError? = nil
) {
self.remoteLibraryResponse = remoteLibraryResponse
self.remoteLibraryError = remoteLibraryError
self.audioAssetData = audioAssetData
self.downloadError = downloadError
}
func registerDevice(
@@ -154,6 +317,20 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
}
func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
_ = assetId
_ = deviceId
if let downloadError {
throw downloadError
}
return audioAssetData ?? Data()
}
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@@ -202,6 +379,16 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
}
}
private func sampleMp3Data(seed: String) -> Data {
Data([
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
] + Array(seed.utf8))
}
private func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private func XCTAssertThrowsErrorAsync(
_ expression: @escaping () async throws -> Void,
file: StaticString = #filePath,