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,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),
};
}
}
@@ -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[];
}
@@ -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],
})
@@ -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([]);
});
});
+67 -1
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(),
},
];
});
}
}