Implement remote library metadata sync for iPhone
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user