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

View File

@ -5,31 +5,38 @@ struct iPhoneLibraryView: View {
var body: some View {
NavigationStack {
List(viewModel.tracks) { track in
VStack(alignment: .leading, spacing: 4) {
List {
Section("Remote tracks: \(viewModel.remoteTracks.count)") {
ForEach(viewModel.remoteTracks) { track in
VStack(alignment: .leading, spacing: 6) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
Text("Duration: \(track.durationText)")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Remote track ID: \(track.remoteTrackID)")
.font(.caption)
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
}
}
.overlay {
if viewModel.tracks.isEmpty {
ContentUnavailableView(
"No Local Tracks Yet",
systemImage: "music.note.list",
description: Text("This iPhone target currently exposes the offline catalog shell only.")
)
}
overlayView
}
.navigationTitle("Velody")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sync") {
Button("Sync Remote Library") {
Task {
await viewModel.refreshSync()
}
}
.disabled(viewModel.state == .loading)
}
}
.safeAreaInset(edge: .bottom) {
@ -45,4 +52,36 @@ struct iPhoneLibraryView: View {
await viewModel.loadIfNeeded()
}
}
@ViewBuilder
private var overlayView: some View {
switch viewModel.state {
case .idle:
if viewModel.remoteTracks.isEmpty {
ContentUnavailableView(
"No Remote Library Yet",
systemImage: "music.note.list",
description: Text("Tap Sync Remote Library to fetch metadata from the backend.")
)
}
case .loading:
ProgressView("Syncing remote library...")
case .success:
EmptyView()
case .empty:
ContentUnavailableView(
"Empty Remote Library",
systemImage: "music.note.list",
description: Text("The backend returned no remote tracks for this iPhone.")
)
case .networkError(let message):
if viewModel.remoteTracks.isEmpty {
ContentUnavailableView(
"Network Error",
systemImage: "wifi.exclamationmark",
description: Text(message)
)
}
}
}
}

View File

@ -4,28 +4,52 @@ import VelodyDomain
import VelodyNetworking
import VelodyPersistence
import VelodySync
import VelodyUtilities
#if canImport(UIKit)
import UIKit
#endif
@MainActor
@Observable
final class iPhoneLibraryViewModel {
var tracks: [LibraryTrack] = []
var syncStatus = "Offline library not synced yet"
enum ViewState: Equatable {
case idle
case loading
case success
case empty
case networkError(String)
}
private let store: any LocalLibraryStore
private let syncCoordinator: PlaceholderSyncCoordinator
var remoteTracks: [RemoteTrackRowViewData] = []
var syncStatus = "Remote library not synced yet."
var state: ViewState = .idle
private let environment: ServerEnvironment
private let apiClient: any VelodyAPIClient
private let syncService: RemoteLibrarySyncService
private let keychainService: any KeychainService
private var hasLoaded = false
init() {
let repository = Self.makeTrackRepository()
init(
keychainService: any KeychainService = SystemKeychainService(
service: "de.diyaa.velody.iphone"
)
) {
let environment = ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "0.1.0"
)
let apiClient = StubVelodyAPIClient(environment: environment)
self.store = repository
self.syncCoordinator = PlaceholderSyncCoordinator(
let apiClient = URLSessionVelodyAPIClient(environment: environment)
let store = Self.makeRemoteLibraryStore()
self.environment = environment
self.apiClient = apiClient
self.keychainService = keychainService
self.syncService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: repository
store: store
)
)
}
@ -34,33 +58,114 @@ final class iPhoneLibraryViewModel {
hasLoaded = true
do {
let persistedTracks = try await store.loadTracks()
if !persistedTracks.isEmpty {
tracks = persistedTracks
syncStatus = "Loaded \(persistedTracks.count) cached track(s) from local storage."
}
let persistedTracks = try await syncService.loadCachedRemoteTracks()
applyRestoredTracks(persistedTracks)
} catch {
syncStatus = "Failed to load cached catalog: \(error.localizedDescription)"
state = .idle
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
}
await refreshSync()
}
func refreshSync() async {
state = .loading
syncStatus = "Syncing remote library..."
do {
let result = try await syncCoordinator.performInitialSync()
tracks = result.tracks
syncStatus = result.statusMessage
let deviceId = try await currentOrRegisterDeviceID()
let tracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
applySyncedTracks(tracks)
} catch {
syncStatus = "Sync placeholder failed: \(error.localizedDescription)"
state = .networkError("Remote library sync failed.")
syncStatus = "Remote library sync failed: \(error.localizedDescription)"
}
}
private static func makeTrackRepository() -> any TrackRepository {
if let repository = try? SwiftDataTrackRepository() {
return repository
private func currentOrRegisterDeviceID() async throws -> String {
if let existingDeviceID = try await keychainService.loadValue(
forKey: Self.deviceIDKey
), !existingDeviceID.isEmpty {
return existingDeviceID
}
return InMemoryTrackRepository()
let response = try await apiClient.registerDevice(
DeviceRegistrationPayload(
platform: .iphone,
deviceName: Self.currentDeviceName,
appVersion: environment.appVersion
)
)
try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey)
try await keychainService.save(
response.bootstrapToken,
forKey: Self.bootstrapTokenKey
)
return response.deviceId
}
private func applyRestoredTracks(_ tracks: [RemoteTrack]) {
remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:))
if tracks.isEmpty {
state = .idle
syncStatus = "Tap Sync Remote Library to load remote metadata."
} else {
state = .success
syncStatus = "Restored \(tracks.count) cached remote track(s)."
}
}
private func applySyncedTracks(_ tracks: [RemoteTrack]) {
remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:))
if tracks.isEmpty {
state = .empty
syncStatus = "Remote library is empty."
} else {
state = .success
syncStatus = "Sync Remote Library completed. Remote tracks: \(tracks.count)."
}
}
private static func makeRemoteLibraryStore() -> any RemoteLibraryStore {
if let store = try? FileRemoteLibraryStore() {
return store
}
return InMemoryRemoteLibraryStore()
}
#if canImport(UIKit)
private static var currentDeviceName: String {
UIDevice.current.name
}
#else
private static let currentDeviceName = "Velody iPhone"
#endif
private static let deviceIDKey = "velody.iphone.device-id"
private static let bootstrapTokenKey = "velody.iphone.bootstrap-token"
}
struct RemoteTrackRowViewData: Identifiable, Equatable {
let id: String
let title: String
let artist: String
let durationText: String
let remoteTrackID: String
init(track: RemoteTrack) {
id = track.trackId
title = track.title
artist = track.artist
durationText = Self.formatDuration(seconds: track.durationSeconds)
remoteTrackID = track.trackId
}
private static func formatDuration(seconds: Int) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
return "\(minutes):\(String(format: "%02d", remainingSeconds))"
}
}

View File

@ -238,6 +238,37 @@
]
}
},
"/api/v1/library/tracks": {
"get": {
"operationId": "LibraryController_getTracks_v1",
"parameters": [
{
"name": "deviceId",
"required": true,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LibraryTracksResponseDto"
}
}
}
}
},
"tags": [
"library"
]
}
},
"/api/v1/sync/bootstrap": {
"get": {
"operationId": "SyncController_bootstrap_v1",
@ -582,6 +613,67 @@
"assetId"
]
},
"RemoteLibraryTrackDto": {
"type": "object",
"properties": {
"trackId": {
"type": "string",
"format": "uuid"
},
"title": {
"type": "string",
"example": "Track Title"
},
"artist": {
"type": "string",
"example": "Track Artist"
},
"durationSeconds": {
"type": "number",
"example": 245
},
"sha256": {
"type": "string",
"example": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"assetId": {
"type": "string",
"format": "uuid"
},
"createdAt": {
"type": "string",
"example": "2026-05-29T08:00:00.000Z"
},
"updatedAt": {
"type": "string",
"example": "2026-05-29T08:05:00.000Z"
}
},
"required": [
"trackId",
"title",
"artist",
"durationSeconds",
"sha256",
"assetId",
"createdAt",
"updatedAt"
]
},
"LibraryTracksResponseDto": {
"type": "object",
"properties": {
"tracks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RemoteLibraryTrackDto"
}
}
},
"required": [
"tracks"
]
},
"LibraryTrackDto": {
"type": "object",
"properties": {

View File

@ -0,0 +1,26 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import {
LibraryTracksQueryDto,
LibraryTracksResponseDto,
} from './library.dto';
import { LibraryService } from './library.service';
@ApiTags('library')
@Controller({
path: 'library',
version: '1',
})
export class LibraryController {
constructor(private readonly libraryService: LibraryService) {}
@Get('tracks')
@ApiOkResponse({ type: LibraryTracksResponseDto })
async getTracks(
@Query() query: LibraryTracksQueryDto,
): Promise<LibraryTracksResponseDto> {
return {
tracks: await this.libraryService.getRemoteLibraryTracks(query.deviceId),
};
}
}

View File

@ -0,0 +1,42 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class LibraryTracksQueryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
deviceId!: string;
}
export class RemoteLibraryTrackDto {
@ApiProperty({ format: 'uuid' })
trackId!: string;
@ApiProperty({ example: 'Track Title' })
title!: string;
@ApiProperty({ example: 'Track Artist' })
artist!: string;
@ApiProperty({ example: 245 })
durationSeconds!: number;
@ApiProperty({
example:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
})
sha256!: string;
@ApiProperty({ format: 'uuid' })
assetId!: string;
@ApiProperty({ example: '2026-05-29T08:00:00.000Z' })
createdAt!: string;
@ApiProperty({ example: '2026-05-29T08:05:00.000Z' })
updatedAt!: string;
}
export class LibraryTracksResponseDto {
@ApiProperty({ type: [RemoteLibraryTrackDto] })
tracks!: RemoteLibraryTrackDto[];
}

View File

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { UsersModule } from '../users/users.module';
import { LibraryController } from './library.controller';
import { LibraryService } from './library.service';
@Module({
imports: [PrismaModule, UsersModule],
controllers: [LibraryController],
providers: [LibraryService],
exports: [LibraryService],
})

View File

@ -0,0 +1,201 @@
import { randomUUID } from 'node:crypto';
import { NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DefaultUserService } from '../users/default-user.service';
import { LibraryService } from './library.service';
function createPrismaMock() {
const devices = new Map<string, any>();
const tracks = new Map<string, any>();
const audioAssets = new Map<string, any>();
return {
prismaMock: {
device: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
const device = devices.get(where.id) ?? null;
return device ? { userId: device.userId } : null;
}),
},
track: {
findMany: jest.fn().mockImplementation(async ({ where }) => {
return [...tracks.values()]
.filter((track) => {
const userMatches = track.userId === where.userId;
const statusMatches = track.status === where.status;
const assetMatches = where.primaryAudioAssetId?.not
? track.primaryAudioAssetId != null
: true;
return userMatches && statusMatches && assetMatches;
})
.sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime())
.map((track) => ({
...track,
primaryAudioAsset: track.primaryAudioAssetId
? audioAssets.get(track.primaryAudioAssetId) ?? null
: null,
}));
}),
},
},
state: {
devices,
tracks,
audioAssets,
},
};
}
describe('LibraryService', () => {
let libraryService: LibraryService;
let state: ReturnType<typeof createPrismaMock>['state'];
beforeEach(async () => {
const { prismaMock, state: nextState } = createPrismaMock();
state = nextState;
const moduleRef = await Test.createTestingModule({
providers: [
LibraryService,
{
provide: PrismaService,
useValue: prismaMock,
},
{
provide: DefaultUserService,
useValue: {},
},
],
}).compile();
libraryService = moduleRef.get(LibraryService);
});
it('returns only tracks owned by the requesting device user in created order', async () => {
const ownerId = randomUUID();
const otherUserId = randomUUID();
const ownerDeviceId = randomUUID();
const ownerTrackId = randomUUID();
const ownerAssetId = randomUUID();
const secondOwnerTrackId = randomUUID();
const secondOwnerAssetId = randomUUID();
const otherTrackId = randomUUID();
const otherAssetId = randomUUID();
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
state.audioAssets.set(ownerAssetId, {
id: ownerAssetId,
sha256: 'sha-owner-a',
durationMs: 181000,
});
state.audioAssets.set(secondOwnerAssetId, {
id: secondOwnerAssetId,
sha256: 'sha-owner-b',
durationMs: 182000,
});
state.audioAssets.set(otherAssetId, {
id: otherAssetId,
sha256: 'sha-other',
durationMs: 183000,
});
state.tracks.set(secondOwnerTrackId, {
id: secondOwnerTrackId,
userId: ownerId,
title: 'Second',
artist: 'Owner',
durationMs: 182000,
status: 'ACTIVE',
primaryAudioAssetId: secondOwnerAssetId,
createdAt: new Date('2026-05-29T08:05:00.000Z'),
updatedAt: new Date('2026-05-29T08:06:00.000Z'),
});
state.tracks.set(ownerTrackId, {
id: ownerTrackId,
userId: ownerId,
title: 'First',
artist: 'Owner',
durationMs: 181000,
status: 'ACTIVE',
primaryAudioAssetId: ownerAssetId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
});
state.tracks.set(otherTrackId, {
id: otherTrackId,
userId: otherUserId,
title: 'Other',
artist: 'Elsewhere',
durationMs: 183000,
status: 'ACTIVE',
primaryAudioAssetId: otherAssetId,
createdAt: new Date('2026-05-29T07:59:00.000Z'),
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
});
const tracks = await libraryService.getRemoteLibraryTracks(ownerDeviceId);
expect(tracks).toEqual([
{
trackId: ownerTrackId,
title: 'First',
artist: 'Owner',
durationSeconds: 181,
sha256: 'sha-owner-a',
assetId: ownerAssetId,
createdAt: '2026-05-29T08:00:00.000Z',
updatedAt: '2026-05-29T08:01:00.000Z',
},
{
trackId: secondOwnerTrackId,
title: 'Second',
artist: 'Owner',
durationSeconds: 182,
sha256: 'sha-owner-b',
assetId: secondOwnerAssetId,
createdAt: '2026-05-29T08:05:00.000Z',
updatedAt: '2026-05-29T08:06:00.000Z',
},
]);
});
it('returns an empty library when the user has no remote tracks', async () => {
const ownerId = randomUUID();
const ownerDeviceId = randomUUID();
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
await expect(
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
});
it('throws for an unknown device', async () => {
await expect(
libraryService.getRemoteLibraryTracks(randomUUID()),
).rejects.toBeInstanceOf(NotFoundException);
});
it('skips tracks without a primary audio asset', async () => {
const ownerId = randomUUID();
const ownerDeviceId = randomUUID();
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId });
state.tracks.set(randomUUID(), {
id: randomUUID(),
userId: ownerId,
title: 'Incomplete',
artist: 'Owner',
durationMs: 181000,
status: 'ACTIVE',
primaryAudioAssetId: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:01:00.000Z'),
});
await expect(
libraryService.getRemoteLibraryTracks(ownerDeviceId),
).resolves.toEqual([]);
});
});

View File

@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { LibraryTrackDto } from '../sync/sync.dto';
import { DefaultUserService } from '../users/default-user.service';
import { RemoteLibraryTrackDto } from './library.dto';
@Injectable()
export class LibraryService {
@ -33,4 +34,69 @@ export class LibraryService {
artist: track.artist,
}));
}
async getRemoteLibraryTracks(
deviceId: string,
): Promise<RemoteLibraryTrackDto[]> {
const device = await this.prismaService.device.findUnique({
where: { id: deviceId },
select: {
userId: true,
},
});
if (!device) {
throw new NotFoundException('Device not found');
}
const tracks = await this.prismaService.track.findMany({
where: {
userId: device.userId,
status: 'ACTIVE',
primaryAudioAssetId: {
not: null,
},
},
orderBy: {
createdAt: 'asc',
},
select: {
id: true,
title: true,
artist: true,
durationMs: true,
createdAt: true,
updatedAt: true,
primaryAudioAsset: {
select: {
id: true,
sha256: true,
durationMs: true,
},
},
},
});
return tracks.flatMap((track) => {
if (!track.primaryAudioAsset) {
return [];
}
const durationMs =
track.durationMs ?? track.primaryAudioAsset.durationMs ?? 0;
return [
{
trackId: track.id,
title: track.title,
artist: track.artist,
durationSeconds: Math.max(0, Math.round(durationMs / 1000)),
sha256: track.primaryAudioAsset.sha256,
assetId: track.primaryAudioAsset.id,
createdAt: track.createdAt.toISOString(),
updatedAt: track.updatedAt.toISOString(),
},
];
});
}
}

View File

@ -9,6 +9,8 @@ import { AppModule } from '../../src/app.module';
import { AppConfigService } from '../../src/modules/config/config.service';
import { DevicesController } from '../../src/modules/devices/devices.controller';
import { HealthController } from '../../src/modules/health/health.controller';
import { LibraryController } from '../../src/modules/library/library.controller';
import { LibraryTracksQueryDto } from '../../src/modules/library/library.dto';
import { SyncController } from '../../src/modules/sync/sync.controller';
import { UploadsController } from '../../src/modules/uploads/uploads.controller';
import { UploadsService } from '../../src/modules/uploads/uploads.service';
@ -72,7 +74,16 @@ function createPrismaMock() {
return record;
}),
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return devices.get(where.id) ?? null;
const device = devices.get(where.id) ?? null;
if (!device) {
return null;
}
if (where.id && typeof where.id === 'string') {
return device;
}
return device;
}),
update: jest.fn().mockImplementation(async ({ where, data }) => {
const current = devices.get(where.id);
@ -91,9 +102,18 @@ function createPrismaMock() {
.filter((track) => {
const userMatches = where?.userId ? track.userId === where.userId : true;
const statusMatches = where?.status ? track.status === where.status : true;
return userMatches && statusMatches;
const assetMatches = where?.primaryAudioAssetId?.not
? track.primaryAudioAssetId != null
: true;
return userMatches && statusMatches && assetMatches;
})
.sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime());
.sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime())
.map((track) => ({
...track,
primaryAudioAsset: track.primaryAudioAssetId
? audioAssets.get(track.primaryAudioAssetId) ?? null
: null,
}));
}),
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return tracks.get(where.id) ?? null;
@ -236,6 +256,7 @@ describe('Velody API wiring (e2e)', () => {
let app: INestApplication;
let healthController: HealthController;
let devicesController: DevicesController;
let libraryController: LibraryController;
let syncController: SyncController;
let uploadsController: UploadsController;
let uploadsService: UploadsService;
@ -274,6 +295,7 @@ describe('Velody API wiring (e2e)', () => {
healthController = moduleRef.get(HealthController);
devicesController = moduleRef.get(DevicesController);
libraryController = moduleRef.get(LibraryController);
syncController = moduleRef.get(SyncController);
uploadsController = moduleRef.get(UploadsController);
uploadsService = moduleRef.get(UploadsService);
@ -322,6 +344,142 @@ describe('Velody API wiring (e2e)', () => {
expect(changesResponse.nextCursor).toBe('0');
});
it('returns remote library metadata for the requesting device owner', async () => {
const primaryDevice = await devicesController.register({
platform: 'IPHONE',
deviceName: 'iPhone',
appVersion: '0.1.0',
});
const secondUserId = randomUUID();
const secondaryDeviceId = randomUUID();
const primaryTrackId = randomUUID();
const primaryAssetId = randomUUID();
const secondaryTrackId = randomUUID();
const secondaryAssetId = randomUUID();
prismaState.devices.set(secondaryDeviceId, {
id: secondaryDeviceId,
userId: secondUserId,
platform: 'IPHONE',
deviceName: 'Other iPhone',
appVersion: '0.1.0',
installTokenHash: 'other-device-hash',
lastSeenAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
prismaState.audioAssets.set(primaryAssetId, {
id: primaryAssetId,
userId: prismaState.defaultUser.id,
trackId: primaryTrackId,
sha256: 'sha-default',
storageKey: 'users/default/audio/sha-default.mp3',
originalFilename: 'default.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(42),
durationMs: 245000,
sourceDeviceId: primaryDevice.deviceId,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
});
prismaState.audioAssets.set(secondaryAssetId, {
id: secondaryAssetId,
userId: secondUserId,
trackId: secondaryTrackId,
sha256: 'sha-other-user',
storageKey: 'users/other/audio/sha-other-user.mp3',
originalFilename: 'other.mp3',
mimeType: 'audio/mpeg',
fileExtension: 'mp3',
fileSizeBytes: BigInt(24),
durationMs: 180000,
sourceDeviceId: secondaryDeviceId,
createdAt: new Date('2026-05-29T08:01:00.000Z'),
});
prismaState.tracks.set(primaryTrackId, {
id: primaryTrackId,
userId: prismaState.defaultUser.id,
primaryAudioAssetId: primaryAssetId,
artworkAssetId: null,
title: 'Default User Track',
artist: 'Velody',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 245000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:00:00.000Z'),
updatedAt: new Date('2026-05-29T08:02:00.000Z'),
});
prismaState.tracks.set(secondaryTrackId, {
id: secondaryTrackId,
userId: secondUserId,
primaryAudioAssetId: secondaryAssetId,
artworkAssetId: null,
title: 'Other User Track',
artist: 'Elsewhere',
album: null,
albumArtist: null,
genre: null,
discNumber: null,
trackNumber: null,
year: null,
durationMs: 180000,
status: 'ACTIVE',
deletedAt: null,
createdAt: new Date('2026-05-29T08:01:00.000Z'),
updatedAt: new Date('2026-05-29T08:03:00.000Z'),
});
const response = await libraryController.getTracks({
deviceId: primaryDevice.deviceId,
});
expect(response).toEqual({
tracks: [
{
trackId: primaryTrackId,
title: 'Default User Track',
artist: 'Velody',
durationSeconds: 245,
sha256: 'sha-default',
assetId: primaryAssetId,
createdAt: '2026-05-29T08:00:00.000Z',
updatedAt: '2026-05-29T08:02:00.000Z',
},
],
});
});
it('rejects an invalid remote library device id query', async () => {
const validationPipe = new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
});
await expect(
validationPipe.transform(
{ deviceId: 'not-a-uuid' },
{
type: 'query',
metatype: LibraryTracksQueryDto,
data: '',
},
),
).rejects.toMatchObject({
response: {
message: expect.arrayContaining(['deviceId must be a UUID']),
},
});
});
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
const registerResponse = await devicesController.register({
platform: 'MACOS',

View File

@ -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
}
}

View File

@ -21,5 +21,12 @@ let package = Package(
name: "VelodyNetworking",
dependencies: ["VelodyDomain"]
),
.testTarget(
name: "VelodyNetworkingTests",
dependencies: [
"VelodyDomain",
"VelodyNetworking",
]
),
]
)

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -27,5 +27,14 @@ let package = Package(
"VelodyPersistence",
]
),
.testTarget(
name: "VelodySyncTests",
dependencies: [
"VelodyDomain",
"VelodyNetworking",
"VelodyPersistence",
"VelodySync",
]
),
]
)

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}