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
+161 -3
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',