Implement incremental sync and offline recovery

This commit is contained in:
diyaa
2026-06-15 22:31:23 +02:00
parent fa7727d572
commit 295c6c1d9b
25 changed files with 2293 additions and 494 deletions
@@ -41,6 +41,12 @@ export class LibraryService {
const { userId: ownerUserId } =
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
return this.getRemoteLibraryTracksForUser(ownerUserId);
}
async getRemoteLibraryTracksForUser(
ownerUserId: string,
): Promise<RemoteLibraryTrackDto[]> {
const tracks = await this.prismaService.track.findMany({
where: {
userId: ownerUserId,
+2 -5
View File
@@ -2,7 +2,6 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { DeviceAuthGuard } from '../auth/device-auth.guard';
import {
SyncBootstrapQueryDto,
SyncBootstrapResponseDto,
SyncChangesQueryDto,
SyncChangesResponseDto,
@@ -21,9 +20,7 @@ export class SyncController {
@Get('bootstrap')
@ApiOkResponse({ type: SyncBootstrapResponseDto })
async bootstrap(
@Query() _query?: SyncBootstrapQueryDto,
): Promise<SyncBootstrapResponseDto> {
async bootstrap(): Promise<SyncBootstrapResponseDto> {
return this.syncService.bootstrap();
}
@@ -32,6 +29,6 @@ export class SyncController {
async changes(
@Query() query: SyncChangesQueryDto,
): Promise<SyncChangesResponseDto> {
return this.syncService.changes(query.after ?? '0');
return this.syncService.changes(query.cursor ?? '0', query.limit);
}
}
+62 -31
View File
@@ -1,5 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID, Matches } from 'class-validator';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
import { RemoteLibraryTrackDto } from '../library/library.dto';
export class LibraryTrackDto {
@ApiProperty({ format: 'uuid', required: false })
@@ -13,54 +15,83 @@ export class LibraryTrackDto {
}
export class SyncEventDto {
@ApiProperty({ example: '3' })
cursor!: string;
@ApiProperty({ example: 'TRACK' })
entityType!: string;
@ApiProperty({ format: 'uuid' })
entityId!: string;
@ApiProperty({ example: 'CREATED' })
@ApiProperty({ example: 'UPDATED' })
action!: string;
@ApiProperty({ example: '0' })
eventId!: string;
}
@ApiProperty({
type: RemoteLibraryTrackDto,
required: false,
nullable: true,
})
track!: RemoteLibraryTrackDto | null;
export class SyncBootstrapResponseDto {
@ApiProperty({ example: '0' })
nextCursor!: string;
@ApiProperty({ type: [LibraryTrackDto] })
tracks!: LibraryTrackDto[];
@ApiProperty({ type: [SyncEventDto] })
events!: SyncEventDto[];
@ApiProperty({ type: [String] })
deletedTrackIds!: string[];
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
serverTime!: string;
}
export class SyncBootstrapQueryDto {
@ApiProperty({
format: 'uuid',
required: false,
description:
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
nullable: true,
})
@IsOptional()
@IsUUID()
deviceId?: string;
deletedTrackId!: string | null;
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
createdAt!: string;
}
export class SyncChangesQueryDto extends SyncBootstrapQueryDto {
export class SyncBootstrapResponseDto {
@ApiProperty({ example: '7' })
nextCursor!: string;
@ApiProperty({ type: [RemoteLibraryTrackDto] })
tracks!: RemoteLibraryTrackDto[];
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
serverTime!: string;
}
export class SyncChangesQueryDto {
@ApiProperty({ required: false, example: '0' })
@IsOptional()
@IsString()
@Matches(/^\d+$/)
after?: string;
cursor?: string;
@ApiProperty({ required: false, example: 100, minimum: 1, maximum: 500 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
}
export class SyncChangesResponseDto extends SyncBootstrapResponseDto {}
export class SyncChangesResponseDto {
@ApiProperty({ example: '7' })
nextCursor!: string;
@ApiProperty({ example: false })
hasMore!: boolean;
@ApiProperty({ example: false })
requiresBootstrap!: boolean;
@ApiProperty({
required: false,
nullable: true,
example: 'cursor_too_old',
})
reason!: string | null;
@ApiProperty({ type: [SyncEventDto] })
events!: SyncEventDto[];
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
serverTime!: string;
}
+287 -45
View File
@@ -1,58 +1,300 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { LibraryService } from '../library/library.service';
import { OwnerContext } from '../users/owner-context.service';
import { SyncService } from './sync.service';
function makeEvent(params: {
userId: string;
cursor: bigint;
entityType?: string;
entityId?: string;
action?: string;
payload?: Record<string, unknown>;
createdAt?: Date;
}) {
return {
id: params.cursor,
userId: params.userId,
cursor: params.cursor,
entityType: params.entityType ?? 'TRACK',
entityId: params.entityId ?? `track-${params.cursor.toString()}`,
action: params.action ?? 'UPDATED',
payloadVersion: 1,
payload: params.payload ?? {},
createdAt:
params.createdAt ??
new Date(`2026-06-15T12:00:0${params.cursor.toString()}.000Z`),
};
}
describe('SyncService', () => {
it('uses OwnerContext to scope the bootstrap cursor lookup', async () => {
const ownerContextMock = {
resolve: jest.fn().mockResolvedValue({
userId: 'bootstrap-owner-id',
}),
};
const prismaMock = {
libraryEvent: {
findFirst: jest.fn().mockResolvedValue({
id: 7n,
it('returns the bootstrap snapshot and persists the device cursor', async () => {
const upsert = jest.fn();
const service = new SyncService(
{
libraryEvent: {
findFirst: jest.fn().mockResolvedValue({ cursor: 7n }),
},
deviceSyncCursor: {
upsert,
},
} as any,
{
getRemoteLibraryTracksForUser: jest.fn().mockResolvedValue([
{
trackId: 'track-123',
title: 'Remote Title',
artist: 'Remote Artist',
durationSeconds: 245,
sha256: 'a'.repeat(64),
assetId: 'asset-123',
createdAt: '2026-06-15T10:00:00.000Z',
updatedAt: '2026-06-15T10:05:00.000Z',
artwork: null,
},
]),
} as any,
{
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: 'device-123',
userId: 'owner-123',
}),
},
};
const libraryServiceMock = {
getBootstrapTracks: jest.fn().mockResolvedValue([]),
};
} as any,
);
const moduleRef = await Test.createTestingModule({
providers: [
SyncService,
{
provide: PrismaService,
useValue: prismaMock,
},
{
provide: LibraryService,
useValue: libraryServiceMock,
},
{
provide: OwnerContext,
useValue: ownerContextMock,
},
],
}).compile();
const service = moduleRef.get(SyncService);
await expect(service.changes('0')).resolves.toMatchObject({
await expect(service.bootstrap()).resolves.toMatchObject({
nextCursor: '7',
tracks: [
expect.objectContaining({
trackId: 'track-123',
}),
],
});
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
expect(prismaMock.libraryEvent.findFirst).toHaveBeenCalledWith({
expect(upsert).toHaveBeenCalledWith({
where: {
userId: 'bootstrap-owner-id',
deviceId: 'device-123',
},
orderBy: {
id: 'desc',
update: {
userId: 'owner-123',
cursor: 7n,
},
create: {
deviceId: 'device-123',
userId: 'owner-123',
cursor: 7n,
},
});
});
it('returns ordered changes after the requested cursor', async () => {
const ownerId = 'owner-123';
const foreignId = 'owner-999';
const events = [
makeEvent({
userId: foreignId,
cursor: 1n,
}),
makeEvent({
userId: ownerId,
cursor: 2n,
payload: {
track: {
trackId: 'track-2',
title: 'Two',
artist: 'Owner',
durationSeconds: 200,
sha256: 'b'.repeat(64),
assetId: 'asset-2',
createdAt: '2026-06-15T10:00:02.000Z',
updatedAt: '2026-06-15T10:00:02.000Z',
artwork: null,
},
},
}),
makeEvent({
userId: ownerId,
cursor: 3n,
action: 'DELETED',
payload: {
deletedTrackId: 'track-3',
},
}),
];
const upsert = jest.fn();
const findFirst = jest.fn().mockImplementation(async ({ where, orderBy }) => {
const filteredEvents = events.filter((event) => event.userId === where.userId);
const direction = orderBy.cursor;
const sortedEvents = [...filteredEvents].sort((lhs, rhs) =>
direction === 'asc'
? Number(lhs.cursor - rhs.cursor)
: Number(rhs.cursor - lhs.cursor),
);
return sortedEvents[0] ?? null;
});
const findMany = jest.fn().mockImplementation(async ({ where, take }) => {
return events
.filter(
(event) =>
event.userId === where.userId && event.cursor > where.cursor.gt,
)
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
.slice(0, take);
});
const service = new SyncService(
{
libraryEvent: {
findFirst,
findMany,
},
deviceSyncCursor: {
upsert,
},
} as any,
{} as any,
{
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: 'device-123',
userId: ownerId,
}),
} as any,
);
const response = await service.changes('1');
expect(response.requiresBootstrap).toBe(false);
expect(response.hasMore).toBe(false);
expect(response.nextCursor).toBe('3');
expect(response.events.map((event) => event.cursor)).toEqual(['2', '3']);
expect(response.events[0]?.track?.trackId).toBe('track-2');
expect(response.events[1]?.deletedTrackId).toBe('track-3');
expect(upsert).toHaveBeenCalledWith({
where: {
deviceId: 'device-123',
},
update: {
userId: ownerId,
cursor: 3n,
},
create: {
deviceId: 'device-123',
userId: ownerId,
cursor: 3n,
},
});
});
it('paginates deterministically and reports hasMore', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-15T08:24:36.011Z'));
try {
const ownerId = 'owner-123';
const events = [1n, 2n, 3n].map((cursor) =>
makeEvent({
userId: ownerId,
cursor,
payload: {
track: {
trackId: `track-${cursor.toString()}`,
title: `Track ${cursor.toString()}`,
artist: 'Owner',
durationSeconds: 180,
sha256: 'c'.repeat(64),
assetId: `asset-${cursor.toString()}`,
createdAt: '2026-06-15T10:00:00.000Z',
updatedAt: '2026-06-15T10:00:00.000Z',
artwork: null,
},
},
}),
);
const findFirst = jest.fn().mockImplementation(async ({ where, orderBy }) => {
const filteredEvents = events.filter((event) => event.userId === where.userId);
const direction = orderBy.cursor;
const sortedEvents = [...filteredEvents].sort((lhs, rhs) =>
direction === 'asc'
? Number(lhs.cursor - rhs.cursor)
: Number(rhs.cursor - lhs.cursor),
);
return sortedEvents[0] ?? null;
});
const findMany = jest.fn().mockImplementation(async ({ where, take }) => {
return events
.filter(
(event) =>
event.userId === where.userId && event.cursor > where.cursor.gt,
)
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
.slice(0, take);
});
const upsert = jest.fn();
const service = new SyncService(
{
libraryEvent: {
findFirst,
findMany,
},
deviceSyncCursor: {
upsert,
},
} as any,
{} as any,
{
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: 'device-123',
userId: ownerId,
}),
} as any,
);
const firstResponse = await service.changes('0', 2);
const replayResponse = await service.changes('0', 2);
expect(firstResponse.hasMore).toBe(true);
expect(firstResponse.nextCursor).toBe('2');
expect(firstResponse.events.map((event) => event.cursor)).toEqual(['1', '2']);
expect(replayResponse).toEqual(firstResponse);
} finally {
jest.useRealTimers();
}
});
it('requires bootstrap when the requested cursor is older than retained history', async () => {
const service = new SyncService(
{
libraryEvent: {
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
if (orderBy.cursor === 'asc') {
return makeEvent({
userId: where.userId,
cursor: 5n,
});
}
return makeEvent({
userId: where.userId,
cursor: 9n,
});
}),
findMany: jest.fn(),
},
deviceSyncCursor: {
upsert: jest.fn(),
},
} as any,
{} as any,
{
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
deviceId: 'device-123',
userId: 'owner-123',
}),
} as any,
);
await expect(service.changes('3')).resolves.toMatchObject({
requiresBootstrap: true,
reason: 'cursor_too_old',
events: [],
hasMore: false,
nextCursor: '3',
});
});
});
+181 -29
View File
@@ -1,57 +1,209 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { RemoteLibraryTrackDto } from '../library/library.dto';
import { LibraryService } from '../library/library.service';
import { OwnerContext } from '../users/owner-context.service';
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
import {
SyncBootstrapResponseDto,
SyncChangesResponseDto,
SyncEventDto,
} from './sync.dto';
interface LibraryEventPayload {
track?: RemoteLibraryTrackDto | null;
deletedTrackId?: string | null;
}
const DEFAULT_SYNC_PAGE_SIZE = 100;
@Injectable()
export class SyncService {
constructor(
private readonly prismaService: PrismaService,
private readonly libraryService: LibraryService,
private readonly ownerContext: OwnerContext,
private readonly deviceAuthService: DeviceAuthService,
) {}
async bootstrap(): Promise<SyncBootstrapResponseDto> {
const latestCursor = await this.getLatestCursor();
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const [tracks, latestCursor] = await Promise.all([
this.libraryService.getRemoteLibraryTracksForUser(device.userId),
this.getLatestCursor(device.userId),
]);
return {
const response: SyncBootstrapResponseDto = {
nextCursor: latestCursor,
tracks: await this.libraryService.getBootstrapTracks(),
events: [],
deletedTrackIds: [],
tracks,
serverTime: new Date().toISOString(),
};
await this.updateDeviceSyncCursor(
device.deviceId,
device.userId,
response.nextCursor,
);
return response;
}
async changes(after: string): Promise<SyncChangesResponseDto> {
const latestCursor = await this.getLatestCursor();
const normalizedCursor =
BigInt(latestCursor) > BigInt(after) ? latestCursor : after;
async changes(
cursor: string,
limit = DEFAULT_SYNC_PAGE_SIZE,
): Promise<SyncChangesResponseDto> {
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
const requestedCursor = BigInt(cursor);
const earliestRetainedCursor = await this.getEarliestRetainedCursor(
device.userId,
);
return {
nextCursor: normalizedCursor,
tracks: [],
events: [],
deletedTrackIds: [],
serverTime: new Date().toISOString(),
};
}
if (
requestedCursor > 0n &&
earliestRetainedCursor !== null &&
requestedCursor < earliestRetainedCursor - 1n
) {
return {
nextCursor: cursor,
hasMore: false,
requiresBootstrap: true,
reason: 'cursor_too_old',
events: [],
serverTime: new Date().toISOString(),
};
}
private async getLatestCursor(): Promise<string> {
const owner = await this.ownerContext.resolve({
allowLegacyDeviceFallback: false,
allowBootstrapFallback: false,
});
const latest = await this.prismaService.libraryEvent.findFirst({
const events = await this.prismaService.libraryEvent.findMany({
where: {
userId: owner.userId,
userId: device.userId,
cursor: {
gt: requestedCursor,
},
},
orderBy: {
id: 'desc',
cursor: 'asc',
},
take: limit + 1,
});
const hasMore = events.length > limit;
const visibleEvents = hasMore ? events.slice(0, limit) : events;
const nextCursor =
visibleEvents.at(-1)?.cursor.toString() ?? requestedCursor.toString();
const response: SyncChangesResponseDto = {
nextCursor,
hasMore,
requiresBootstrap: false,
reason: null,
events: visibleEvents.map((event) => this.toSyncEventDto(event)),
serverTime: new Date().toISOString(),
};
await this.updateDeviceSyncCursor(
device.deviceId,
device.userId,
response.nextCursor,
);
return response;
}
private async getLatestCursor(userId: string): Promise<string> {
const latest = await this.prismaService.libraryEvent.findFirst({
where: {
userId,
},
orderBy: {
cursor: 'desc',
},
select: {
cursor: true,
},
});
return latest?.id.toString() ?? '0';
return latest?.cursor.toString() ?? '0';
}
private async getEarliestRetainedCursor(
userId: string,
): Promise<bigint | null> {
const earliest = await this.prismaService.libraryEvent.findFirst({
where: {
userId,
},
orderBy: {
cursor: 'asc',
},
select: {
cursor: true,
},
});
return earliest?.cursor ?? null;
}
private async updateDeviceSyncCursor(
deviceId: string,
userId: string,
cursor: string,
): Promise<void> {
await this.prismaService.deviceSyncCursor.upsert({
where: {
deviceId,
},
update: {
userId,
cursor: BigInt(cursor),
},
create: {
deviceId,
userId,
cursor: BigInt(cursor),
},
});
}
private toSyncEventDto(event: {
cursor: bigint;
entityType: string;
entityId: string;
action: string;
payload: unknown;
createdAt: Date;
}): SyncEventDto {
const payload = this.parsePayload(event.payload);
const deletedTrackId =
payload.deletedTrackId ??
(event.action === 'DELETED' && event.entityType === 'TRACK'
? event.entityId
: null);
return {
cursor: event.cursor.toString(),
entityType: event.entityType,
entityId: event.entityId,
action: event.action,
track: payload.track ?? null,
deletedTrackId,
createdAt: event.createdAt.toISOString(),
};
}
private parsePayload(payload: unknown): LibraryEventPayload {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return {};
}
const record = payload as Record<string, unknown>;
const track =
record.track && typeof record.track === 'object' && !Array.isArray(record.track)
? (record.track as RemoteLibraryTrackDto)
: null;
const deletedTrackId =
typeof record.deletedTrackId === 'string' ? record.deletedTrackId : null;
return {
track,
deletedTrackId,
};
}
}
@@ -28,6 +28,7 @@ function createPrismaMock() {
slug: 'default-owner',
displayName: 'Default Owner',
isDefault: true,
libraryCursor: 0n,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -38,6 +39,24 @@ function createPrismaMock() {
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
user: {
upsert: jest.fn().mockResolvedValue(defaultUser),
update: jest.fn().mockImplementation(async ({ where, data, select }) => {
const current = users.get(where.id);
const incrementBy = BigInt(data.libraryCursor?.increment ?? 0);
const updated = {
...current,
libraryCursor: BigInt(current.libraryCursor ?? 0) + incrementBy,
updatedAt: new Date(),
};
users.set(where.id, updated);
if (select?.libraryCursor) {
return {
libraryCursor: updated.libraryCursor,
};
}
return updated;
}),
},
device: {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
@@ -218,11 +237,17 @@ function createPrismaMock() {
nextLibraryEventId += 1n;
return record;
}),
findFirst: jest.fn().mockImplementation(async ({ where }) => {
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
const filteredEvents = [...libraryEvents.values()].filter((event) =>
where?.userId ? event.userId === where.userId : true,
);
return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null;
const direction = orderBy?.cursor ?? 'desc';
return filteredEvents
.sort((lhs, rhs) =>
direction === 'asc'
? Number(lhs.cursor - rhs.cursor)
: Number(rhs.cursor - lhs.cursor),
)[0] ?? null;
}),
},
state: {
@@ -463,15 +488,27 @@ describe('UploadsService', () => {
expect(finalizeResponse.assetId).toBeDefined();
expect(state.tracks.size).toBe(1);
expect(state.audioAssets.size).toBe(1);
expect(state.libraryEvents.size).toBe(1);
expect(state.libraryEvents.size).toBe(2);
const track = [...state.tracks.values()][0];
const audioAsset = [...state.audioAssets.values()][0];
const libraryEvent = [...state.libraryEvents.values()][0];
const libraryEvents = [...state.libraryEvents.values()].sort((lhs, rhs) =>
Number(lhs.cursor - rhs.cursor),
);
expect(track.userId).toBe(state.defaultUser.id);
expect(audioAsset.userId).toBe(state.defaultUser.id);
expect(libraryEvent.userId).toBe(state.defaultUser.id);
expect(libraryEvents.map((event) => event.entityType)).toEqual([
'TRACK',
'AUDIO_ASSET',
]);
expect(libraryEvents.map((event) => event.action)).toEqual([
'CREATED',
'CREATED',
]);
expect(libraryEvents[0]?.payload.track.trackId).toBe(track.id);
expect(libraryEvents[1]?.payload.track.assetId).toBe(audioAsset.id);
expect(state.users.get(state.defaultUser.id)?.libraryCursor).toBe(2n);
const session = state.uploadSessions.get(response.uploadId!);
expect(session.finalizedAt).toBeInstanceOf(Date);
+192 -14
View File
@@ -7,6 +7,7 @@ import {
import {
EntityType,
EventAction,
Prisma,
type UploadSession,
UploadSessionStatus,
} from '@prisma/client';
@@ -18,6 +19,7 @@ import { extname } from 'node:path';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AppConfigService } from '../config/config.service';
import { DeviceAuthService } from '../auth/device-auth.service';
import { RemoteLibraryTrackDto } from '../library/library.dto';
import { LocalFilesystemStorageService } from '../storage/storage.service';
import { OwnerContext } from '../users/owner-context.service';
import {
@@ -38,6 +40,11 @@ interface PreparedArtworkAssetInput {
fileSizeBytes: bigint;
}
interface LibraryEventPayload {
track?: RemoteLibraryTrackDto;
deletedTrackId?: string;
}
@Injectable()
export class UploadsService {
constructor(
@@ -367,6 +374,8 @@ export class UploadsService {
}
const createdTrack = !track;
let trackMetadataChanged = false;
if (!track) {
track = await tx.track.create({
data: {
@@ -378,8 +387,34 @@ export class UploadsService {
status: 'ACTIVE',
},
});
} else {
const nextTrackDurationMs = body.durationMs ?? track.durationMs;
const shouldUpdateTrack =
track.title !== title ||
track.artist !== artist ||
(track.album ?? null) !== album ||
(track.durationMs ?? null) !== (nextTrackDurationMs ?? null) ||
track.status !== 'ACTIVE' ||
track.deletedAt !== null;
if (shouldUpdateTrack) {
track = await tx.track.update({
where: { id: track.id },
data: {
title,
artist,
album,
durationMs: nextTrackDurationMs,
status: 'ACTIVE',
deletedAt: null,
},
});
trackMetadataChanged = true;
}
}
const createdAudioAsset = !audioAsset;
let audioAssetChanged = createdAudioAsset;
if (audioAsset) {
const nextDurationMs = body.durationMs ?? audioAsset.durationMs;
const shouldUpdateAsset =
@@ -404,6 +439,7 @@ export class UploadsService {
durationMs: nextDurationMs,
},
});
audioAssetChanged = true;
}
} else {
audioAsset = await tx.audioAsset.create({
@@ -422,6 +458,7 @@ export class UploadsService {
});
}
let primaryAudioAssetChanged = false;
if (track.primaryAudioAssetId !== audioAsset.id) {
track = await tx.track.update({
where: { id: track.id },
@@ -429,18 +466,21 @@ export class UploadsService {
primaryAudioAssetId: audioAsset.id,
},
});
primaryAudioAssetChanged = true;
}
const artworkAssetId = preparedArtwork
? (
await this.findOrCreateArtworkAsset(
tx,
ownerUserId,
preparedArtwork,
)
).id
const priorArtworkAssetId = track.artworkAssetId ?? null;
const artworkResult = preparedArtwork
? await this.findOrCreateArtworkAsset(
tx,
ownerUserId,
preparedArtwork,
)
: null;
const artworkAsset = artworkResult?.artworkAsset ?? null;
const artworkAssetId = artworkAsset?.id ?? null;
let artworkLinkChanged = false;
if ((track.artworkAssetId ?? null) !== artworkAssetId) {
track = await tx.track.update({
where: { id: track.id },
@@ -448,16 +488,57 @@ export class UploadsService {
artworkAssetId,
},
});
artworkLinkChanged = true;
}
await tx.libraryEvent.create({
data: {
const finalTrackSnapshot = this.buildRemoteLibraryTrackDto(
track,
audioAsset,
artworkAssetId ? artworkAsset : null,
);
const eventPayload: LibraryEventPayload = {
track: finalTrackSnapshot,
};
if (createdTrack || trackMetadataChanged) {
await this.appendLibraryEvent(tx, {
userId: ownerUserId,
entityType: EntityType.TRACK,
entityId: track.id,
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
},
});
payload: eventPayload,
});
}
if (audioAssetChanged || primaryAudioAssetChanged) {
await this.appendLibraryEvent(tx, {
userId: ownerUserId,
entityType: EntityType.AUDIO_ASSET,
entityId: audioAsset.id,
action: createdAudioAsset ? EventAction.CREATED : EventAction.UPDATED,
payload: eventPayload,
});
}
if (
artworkResult?.wasCreated ||
artworkResult?.wasUpdated ||
artworkLinkChanged ||
(priorArtworkAssetId !== null && artworkAssetId === null)
) {
await this.appendLibraryEvent(tx, {
userId: ownerUserId,
entityType: EntityType.ARTWORK_ASSET,
entityId: artworkAssetId ?? priorArtworkAssetId!,
action:
artworkAssetId == null
? EventAction.DELETED
: artworkResult?.wasCreated
? EventAction.CREATED
: EventAction.UPDATED,
payload: eventPayload,
});
}
await tx.uploadSession.update({
where: { id: currentSession.id },
@@ -542,6 +623,87 @@ export class UploadsService {
}
}
private buildRemoteLibraryTrackDto(
track: {
id: string;
title: string;
artist: string;
durationMs: number | null;
createdAt: Date;
updatedAt: Date;
},
audioAsset: {
id: string;
sha256: string;
durationMs: number | null;
},
artworkAsset: {
id: string;
sha256: string;
mimeType: string;
width: number | null;
height: number | null;
} | null,
): RemoteLibraryTrackDto {
const durationMs = track.durationMs ?? audioAsset.durationMs ?? 0;
return {
trackId: track.id,
title: track.title,
artist: track.artist,
durationSeconds: Math.max(0, Math.round(durationMs / 1000)),
sha256: audioAsset.sha256,
assetId: audioAsset.id,
createdAt: track.createdAt.toISOString(),
updatedAt: track.updatedAt.toISOString(),
artwork: artworkAsset
? {
artworkId: artworkAsset.id,
sha256: artworkAsset.sha256,
mimeType: artworkAsset.mimeType,
width: artworkAsset.width,
height: artworkAsset.height,
}
: null,
};
}
private async appendLibraryEvent(
tx: Pick<PrismaService, 'user' | 'libraryEvent'>,
params: {
userId: string;
entityType: EntityType;
entityId: string;
action: EventAction;
payload: LibraryEventPayload;
},
): Promise<void> {
const owner = await tx.user.update({
where: {
id: params.userId,
},
data: {
libraryCursor: {
increment: 1,
},
},
select: {
libraryCursor: true,
},
});
await tx.libraryEvent.create({
data: {
userId: params.userId,
cursor: owner.libraryCursor,
entityType: params.entityType,
entityId: params.entityId,
action: params.action,
payload: params.payload as Prisma.InputJsonValue,
},
});
}
private toStatusResponse(
uploadSession: Pick<
UploadSession,
@@ -636,12 +798,22 @@ export class UploadsService {
fileSizeBytes: artwork.fileSizeBytes,
},
});
return {
artworkAsset,
wasCreated: false,
wasUpdated: true,
};
}
return artworkAsset;
return {
artworkAsset,
wasCreated: false,
wasUpdated: false,
};
}
return tx.artworkAsset.create({
const createdArtworkAsset = await tx.artworkAsset.create({
data: {
userId,
sha256: artwork.sha256,
@@ -652,6 +824,12 @@ export class UploadsService {
fileSizeBytes: artwork.fileSizeBytes,
},
});
return {
artworkAsset: createdArtworkAsset,
wasCreated: true,
wasUpdated: false,
};
}
private assertMp3Filename(filename: string): void {
@@ -8,6 +8,7 @@ describe('DefaultUserService', () => {
slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true,
libraryCursor: 0n,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -48,6 +49,7 @@ describe('DefaultUserService', () => {
slug: DefaultUserService.defaultOwnerSlug,
displayName: DefaultUserService.defaultOwnerDisplayName,
isDefault: true,
libraryCursor: 0n,
createdAt: new Date(),
updatedAt: new Date(),
});