Implement Milestone 7.2 offline audio downloads
This commit is contained in:
@@ -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 {
|
||||
|
||||
+209
@@ -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
|
||||
}
|
||||
}
|
||||
+93
@@ -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
|
||||
}
|
||||
}
|
||||
+133
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+191
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user