Implement remote library metadata sync for iPhone

This commit is contained in:
diyaa
2026-05-29 08:54:09 +02:00
parent ebc187f4a1
commit 6e73c1878e
20 changed files with 1379 additions and 48 deletions
@@ -0,0 +1,34 @@
import Foundation
public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable {
public var id: String { trackId }
public var trackId: String
public var title: String
public var artist: String
public var durationSeconds: Int
public var sha256: String
public var assetId: String
public var createdAt: String
public var updatedAt: String
public init(
trackId: String,
title: String,
artist: String,
durationSeconds: Int,
sha256: String,
assetId: String,
createdAt: String,
updatedAt: String
) {
self.trackId = trackId
self.title = title
self.artist = artist
self.durationSeconds = durationSeconds
self.sha256 = sha256
self.assetId = assetId
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
@@ -21,5 +21,12 @@ let package = Package(
name: "VelodyNetworking",
dependencies: ["VelodyDomain"]
),
.testTarget(
name: "VelodyNetworkingTests",
dependencies: [
"VelodyDomain",
"VelodyNetworking",
]
),
]
)
@@ -0,0 +1,54 @@
import Foundation
import VelodyDomain
public struct RemoteTrackDTO: Codable, Hashable, Sendable {
public var trackId: String
public var title: String
public var artist: String
public var durationSeconds: Int
public var sha256: String
public var assetId: String
public var createdAt: String
public var updatedAt: String
public init(
trackId: String,
title: String,
artist: String,
durationSeconds: Int,
sha256: String,
assetId: String,
createdAt: String,
updatedAt: String
) {
self.trackId = trackId
self.title = title
self.artist = artist
self.durationSeconds = durationSeconds
self.sha256 = sha256
self.assetId = assetId
self.createdAt = createdAt
self.updatedAt = updatedAt
}
public var remoteTrack: RemoteTrack {
RemoteTrack(
trackId: trackId,
title: title,
artist: artist,
durationSeconds: durationSeconds,
sha256: sha256,
assetId: assetId,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
public struct RemoteLibraryResponseDTO: Codable, Hashable, Sendable {
public var tracks: [RemoteTrackDTO]
public init(tracks: [RemoteTrackDTO]) {
self.tracks = tracks
}
}
@@ -39,6 +39,10 @@ public protocol VelodyAPIClient: Sendable {
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse
@@ -106,6 +110,19 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
)
}
public func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "library", "tracks"],
queryItems: [
URLQueryItem(name: "deviceId", value: deviceId),
],
responseType: RemoteLibraryResponseDTO.self
)
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@@ -139,6 +156,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
let request = try buildRequest(
method: "PUT",
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
queryItems: [],
bodyData: nil,
contentType: mimeType
)
@@ -174,11 +192,13 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
private func sendRequest<Response: Decodable>(
method: String,
pathComponents: [String],
queryItems: [URLQueryItem] = [],
responseType: Response.Type
) async throws -> Response {
let request = try buildRequest(
method: method,
pathComponents: pathComponents,
queryItems: queryItems,
bodyData: nil
)
@@ -188,6 +208,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
private func sendRequest<Body: Encodable, Response: Decodable>(
method: String,
pathComponents: [String],
queryItems: [URLQueryItem] = [],
body: Body,
responseType: Response.Type
) async throws -> Response {
@@ -202,6 +223,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
let request = try buildRequest(
method: method,
pathComponents: pathComponents,
queryItems: queryItems,
bodyData: bodyData,
contentType: "application/json"
)
@@ -212,10 +234,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
private func buildRequest(
method: String,
pathComponents: [String],
queryItems: [URLQueryItem],
bodyData: Data?,
contentType: String? = nil
) throws -> URLRequest {
guard let url = endpointURL(pathComponents: pathComponents) else {
guard let url = endpointURL(
pathComponents: pathComponents,
queryItems: queryItems
) else {
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
}
@@ -284,10 +310,24 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
}
}
private func endpointURL(pathComponents: [String]) -> URL? {
pathComponents.reduce(environment.baseURL) { partialURL, component in
private func endpointURL(
pathComponents: [String],
queryItems: [URLQueryItem]
) -> URL? {
let baseURL = pathComponents.reduce(environment.baseURL) { partialURL, component in
partialURL.appendingPathComponent(component, isDirectory: false)
}
guard !queryItems.isEmpty else {
return baseURL
}
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
return nil
}
components.queryItems = queryItems
return components.url
}
}
@@ -340,6 +380,27 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
)
}
public func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
_ = deviceId
return RemoteLibraryResponseDTO(
tracks: [
RemoteTrackDTO(
trackId: UUID().uuidString,
title: "Velody Remote Placeholder",
artist: "Private Library",
durationSeconds: 245,
sha256: String(repeating: "a", count: 64),
assetId: UUID().uuidString,
createdAt: ISO8601DateFormatter().string(from: .now),
updatedAt: ISO8601DateFormatter().string(from: .now)
),
]
)
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
@@ -0,0 +1,54 @@
import Foundation
import XCTest
@testable import VelodyNetworking
final class RemoteLibraryDTOTests: XCTestCase {
func testRemoteTrackDTOMapsToRemoteTrack() {
let dto = 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"
)
let track = dto.remoteTrack
XCTAssertEqual(track.trackId, "track-123")
XCTAssertEqual(track.title, "Remote Title")
XCTAssertEqual(track.artist, "Remote Artist")
XCTAssertEqual(track.durationSeconds, 245)
XCTAssertEqual(track.sha256, String(repeating: "a", count: 64))
XCTAssertEqual(track.assetId, "asset-456")
}
func testRemoteLibraryResponseDTODecodesFromAPIResponse() throws {
let data = Data(
"""
{
"tracks": [
{
"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"
}
]
}
""".utf8
)
let decoded = try JSONDecoder().decode(RemoteLibraryResponseDTO.self, from: data)
XCTAssertEqual(decoded.tracks.count, 1)
XCTAssertEqual(decoded.tracks.first?.trackId, "track-123")
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
}
}
@@ -0,0 +1,74 @@
import Foundation
import VelodyDomain
public protocol RemoteLibraryStore: Actor {
func loadRemoteTracks() async throws -> [RemoteTrack]
func replaceRemoteTracks(_ tracks: [RemoteTrack]) async throws
}
public actor FileRemoteLibraryStore: RemoteLibraryStore {
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)
}
}
public func loadRemoteTracks() async throws -> [RemoteTrack] {
guard fileManager.fileExists(atPath: fileURL.path) else {
return []
}
let data = try Data(contentsOf: fileURL)
return try decoder.decode([RemoteTrack].self, from: data)
}
public func replaceRemoteTracks(_ tracks: [RemoteTrack]) async throws {
try fileManager.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try encoder.encode(tracks)
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-library.json")
}
}
public actor InMemoryRemoteLibraryStore: RemoteLibraryStore {
private var tracks: [RemoteTrack]
public init(tracks: [RemoteTrack] = []) {
self.tracks = tracks
}
public func loadRemoteTracks() async throws -> [RemoteTrack] {
tracks
}
public func replaceRemoteTracks(_ tracks: [RemoteTrack]) async throws {
self.tracks = tracks
}
}
@@ -0,0 +1,40 @@
import Foundation
import XCTest
import VelodyDomain
@testable import VelodyPersistence
final class RemoteLibraryStoreTests: XCTestCase {
func testFileRemoteLibraryStorePersistsTracksAcrossInstances() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let fileURL = tempDirectory.appendingPathComponent("remote-library.json")
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstStore = try FileRemoteLibraryStore(fileURL: fileURL)
let tracks = [
RemoteTrack(
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"
),
]
try await firstStore.replaceRemoteTracks(tracks)
let secondStore = try FileRemoteLibraryStore(fileURL: fileURL)
let restoredTracks = try await secondStore.loadRemoteTracks()
XCTAssertEqual(restoredTracks, tracks)
}
}
+9
View File
@@ -27,5 +27,14 @@ let package = Package(
"VelodyPersistence",
]
),
.testTarget(
name: "VelodySyncTests",
dependencies: [
"VelodyDomain",
"VelodyNetworking",
"VelodyPersistence",
"VelodySync",
]
),
]
)
@@ -0,0 +1,33 @@
import Foundation
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
public protocol RemoteLibraryRepository: Actor {
func loadCachedRemoteTracks() async throws -> [RemoteTrack]
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack]
}
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
private let apiClient: any VelodyAPIClient
private let store: any RemoteLibraryStore
public init(
apiClient: any VelodyAPIClient,
store: any RemoteLibraryStore
) {
self.apiClient = apiClient
self.store = store
}
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
try await store.loadRemoteTracks()
}
public func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
let response = try await apiClient.fetchRemoteLibrary(deviceId: deviceId)
let tracks = response.tracks.map(\.remoteTrack)
try await store.replaceRemoteTracks(tracks)
return tracks
}
}
@@ -0,0 +1,18 @@
import Foundation
import VelodyDomain
public actor RemoteLibrarySyncService {
private let repository: any RemoteLibraryRepository
public init(repository: any RemoteLibraryRepository) {
self.repository = repository
}
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
try await repository.loadCachedRemoteTracks()
}
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
try await repository.syncRemoteTracks(deviceId: deviceId)
}
}
@@ -0,0 +1,216 @@
import Foundation
import XCTest
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
@testable import VelodySync
final class RemoteLibrarySyncServiceTests: XCTestCase {
func testSuccessfulSyncPersistsRemoteTracks() async throws {
let store = InMemoryRemoteLibraryStore()
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"
),
]
)
),
store: store
)
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedTracks = try await service.loadCachedRemoteTracks()
XCTAssertEqual(tracks.count, 1)
XCTAssertEqual(cachedTracks, tracks)
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
}
func testEmptyResponseClearsCachedRemoteLibrary() async throws {
let store = InMemoryRemoteLibraryStore(
tracks: [
RemoteTrack(
trackId: "track-123",
title: "Old",
artist: "Artist",
durationSeconds: 100,
sha256: String(repeating: "b", count: 64),
assetId: "asset-123",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
),
]
)
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])
),
store: store
)
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedTracks = try await service.loadCachedRemoteTracks()
XCTAssertEqual(tracks, [])
XCTAssertEqual(cachedTracks, [])
}
func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws {
let cachedTrack = RemoteTrack(
trackId: "track-123",
title: "Cached",
artist: "Artist",
durationSeconds: 100,
sha256: String(repeating: "c", count: 64),
assetId: "asset-123",
createdAt: "2026-05-29T08:00:00.000Z",
updatedAt: "2026-05-29T08:05:00.000Z"
)
let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
),
store: store
)
)
await XCTAssertThrowsErrorAsync {
_ = try await service.syncRemoteLibrary(deviceId: "device-123")
}
let cachedTracks = try await service.loadCachedRemoteTracks()
XCTAssertEqual(cachedTracks, [cachedTrack])
}
}
private struct MockVelodyAPIClient: VelodyAPIClient {
let remoteLibraryResponse: RemoteLibraryResponseDTO?
let remoteLibraryError: VelodyAPIError?
init(
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
remoteLibraryError: VelodyAPIError? = nil
) {
self.remoteLibraryResponse = remoteLibraryResponse
self.remoteLibraryError = remoteLibraryError
}
func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
bootstrapToken: UUID().uuidString,
serverTime: "2026-05-29T08:00:00.000Z"
)
}
func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse {
_ = payload
return DeviceHeartbeatResponse(
ok: true,
serverTime: "2026-05-29T08:00:00.000Z"
)
}
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
SyncBootstrapResponse(
nextCursor: SyncCursor(value: "0"),
tracks: [],
events: [],
deletedTrackIds: [],
serverTime: "2026-05-29T08:00:00.000Z"
)
}
func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
_ = deviceId
if let remoteLibraryError {
throw remoteLibraryError
}
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
}
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
_ = payload
return UploadPrepareResponse(status: .uploadRequired, uploadId: UUID().uuidString, nextOffset: 0)
}
func fetchUploadStatus(
uploadId: String
) async throws -> UploadSessionStatusResponse {
UploadSessionStatusResponse(
uploadId: uploadId,
status: .completed,
receivedBytes: "0",
expectedSizeBytes: "0",
nextOffset: "0"
)
}
func uploadFile(
uploadId: String,
fileURL: URL,
mimeType: String
) async throws -> UploadSessionStatusResponse {
_ = fileURL
_ = mimeType
return UploadSessionStatusResponse(
uploadId: uploadId,
status: .completed,
receivedBytes: "0",
expectedSizeBytes: "0",
nextOffset: "0"
)
}
func finalizeUpload(
uploadId: String,
payload: UploadFinalizeRequest
) async throws -> UploadFinalizeResponse {
_ = uploadId
_ = payload
return UploadFinalizeResponse(
trackId: UUID().uuidString,
assetId: UUID().uuidString
)
}
}
private func XCTAssertThrowsErrorAsync(
_ expression: @escaping () async throws -> Void,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
try await expression()
XCTFail("Expected expression to throw an error.", file: file, line: line)
} catch {
XCTAssertTrue(true)
}
}