diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index fa5b18f..4b3aa9b 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -476,13 +476,15 @@ final class iPhoneLibraryViewModel { } ) let store = Self.makeRemoteLibraryStore() + let syncCursorStore = Self.makeRemoteLibrarySyncCursorStore() let downloadStateStore = Self.makeRemoteTrackDownloadStateStore() let audioFileStore = Self.makeOfflineAudioFileStore() let artworkStore = Self.makeArtworkStore() let favoriteTrackStore = Self.makeFavoriteTrackStore() let repository = DefaultRemoteLibraryRepository( apiClient: apiClient, - store: store + store: store, + syncCursorStore: syncCursorStore ) let syncService = RemoteLibrarySyncService( repository: repository, @@ -726,6 +728,14 @@ final class iPhoneLibraryViewModel { return InMemoryRemoteTrackDownloadStateStore() } + private static func makeRemoteLibrarySyncCursorStore() -> any RemoteLibrarySyncCursorStore { + if let store = try? FileRemoteLibrarySyncCursorStore() { + return store + } + + return InMemoryRemoteLibrarySyncCursorStore() + } + private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore { if let store = try? FileOfflineAudioFileStore() { return store diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift index 05e0cbe..4896041 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelFavoritesTests.swift @@ -153,6 +153,36 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase { XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite) } + func testFavoritesRemainIntactAcrossLibrarySync() async throws { + let track = makeRemoteTrack( + trackId: "remote-sync-favorite", + assetId: "asset-sync-favorite", + title: "Sync Favorite" + ) + let favoriteStore = InMemoryFavoriteTrackStore() + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + favoriteTrackStore: favoriteStore, + audioFiles: [localFilePath(for: track): Data([0x5])], + apiClient: StubVelodyAPIClient( + environment: ServerEnvironment( + baseURL: ServerEnvironment.defaultLocalBaseURL, + appVersion: "Tests" + ) + ) + ) + + await viewModel.loadIfNeeded() + await viewModel.toggleFavorite(trackID: track.trackId) + await viewModel.refreshSync() + + XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite) + XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite) + let savedFavorites = try await favoriteStore.loadFavoriteTracks() + XCTAssertEqual(savedFavorites.map(\.remoteTrackId), [track.trackId]) + } + func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws { let track = makeRemoteTrack( trackId: "remote-repeat", @@ -285,4 +315,41 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase { XCTAssertTrue(remoteTrack.isFavorite) XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) } + + func testFailedDownloadKeepsFavoriteAndShowsRetry() async throws { + let track = makeRemoteTrack( + trackId: "remote-interrupted-favorite", + assetId: "asset-interrupted-favorite", + title: "Interrupted Favorite" + ) + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [ + RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + localFilePath: "", + downloadedAt: nil, + downloadStatus: .failed, + lastDownloadError: "The previous download did not finish. Try again." + ), + ], + favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [ + FavoriteTrackRecord( + remoteTrackId: track.trackId, + favoritedAt: Date(timeIntervalSince1970: 7_000) + ), + ]) + ) + + await viewModel.loadIfNeeded() + + let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)) + + XCTAssertEqual(remoteTrack.status, .failed) + XCTAssertEqual(remoteTrack.downloadButtonTitle, "Retry") + XCTAssertTrue(remoteTrack.canDownload) + XCTAssertTrue(remoteTrack.isFavorite) + XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) + } } diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift index cee0002..e1e29c5 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift @@ -4,6 +4,7 @@ import VelodyDomain import VelodyNetworking import VelodyPlayback import VelodyPersistence +import VelodyUtilities @testable import VelodyiPhone @MainActor @@ -449,9 +450,35 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase { let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)) XCTAssertEqual(remoteTrack.status, .missing) XCTAssertEqual(remoteTrack.statusBadgeTitle, "Missing") + XCTAssertTrue(remoteTrack.canDownload) + XCTAssertEqual(remoteTrack.downloadButtonTitle, "Re-download") + XCTAssertFalse(remoteTrack.canPlay) XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)") } + + func testDownloadedTrackAppearsInAvailableOfflineState() async throws { + let track = makeRemoteTrack( + trackId: "remote-recovered-download", + assetId: "asset-recovered-download", + title: "Recovered Download" + ) + let viewModel = makeViewModel( + remoteTracks: [track], + downloadStates: [makeDownloadedState(for: track)], + audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])] + ) + + await viewModel.loadIfNeeded() + + let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)) + let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)) + + XCTAssertEqual(remoteTrack.status, .downloaded) + XCTAssertEqual(remoteTrack.statusBadgeTitle, "Downloaded") + XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded") + XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (1)") + } } @MainActor @@ -479,7 +506,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase { forKey: "velody.iphone.device-access-token" ) - XCTAssertEqual(await counter.count, 1) + let registerCount = await counter.currentCount() + XCTAssertEqual(registerCount, 1) XCTAssertFalse((storedDeviceID ?? "").isEmpty) XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty) } @@ -513,7 +541,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase { forKey: "velody.iphone.device-access-token" ) - XCTAssertEqual(await counter.count, 1) + let registerCount = await counter.currentCount() + XCTAssertEqual(registerCount, 1) XCTAssertNotEqual(storedDeviceID, legacyDeviceID) XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty) } @@ -525,6 +554,10 @@ private actor RegisterCallCounter { func increment() { count += 1 } + + func currentCount() -> Int { + count + } } private struct TestRegisterAPIClient: VelodyAPIClient { @@ -563,6 +596,12 @@ private struct TestRegisterAPIClient: VelodyAPIClient { try await stubClient.fetchSyncBootstrap() } + func fetchSyncChanges( + cursor: SyncCursor + ) async throws -> SyncChangesResponse { + try await stubClient.fetchSyncChanges(cursor: cursor) + } + func fetchRemoteLibrary( deviceId: String ) async throws -> RemoteLibraryResponseDTO { diff --git a/backend/openapi/velody.openapi.json b/backend/openapi/velody.openapi.json index 33e7694..b33f509 100644 --- a/backend/openapi/velody.openapi.json +++ b/backend/openapi/velody.openapi.json @@ -395,18 +395,7 @@ "/api/v1/sync/bootstrap": { "get": { "operationId": "SyncController_bootstrap_v1", - "parameters": [ - { - "name": "deviceId", - "required": false, - "in": "query", - "description": "Optional client metadata. Authorization: Bearer is required and determines access.", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], + "parameters": [], "responses": { "200": { "description": "", @@ -434,23 +423,24 @@ "operationId": "SyncController_changes_v1", "parameters": [ { - "name": "deviceId", - "required": false, - "in": "query", - "description": "Optional client metadata. Authorization: Bearer is required and determines access.", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "after", + "name": "cursor", "required": false, "in": "query", "schema": { "example": "0", "type": "string" } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 500, + "example": 100, + "type": "number" + } } ], "responses": { @@ -918,26 +908,37 @@ "tracks" ] }, - "LibraryTrackDto": { + "SyncBootstrapResponseDto": { "type": "object", "properties": { - "id": { + "nextCursor": { "type": "string", - "format": "uuid" + "example": "7" }, - "title": { - "type": "string", - "example": "Placeholder Track" + "tracks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryTrackDto" + } }, - "artist": { + "serverTime": { "type": "string", - "example": "Velody" + "example": "2026-06-15T12:00:00.000Z" } - } + }, + "required": [ + "nextCursor", + "tracks", + "serverTime" + ] }, "SyncEventDto": { "type": "object", "properties": { + "cursor": { + "type": "string", + "example": "3" + }, "entityType": { "type": "string", "example": "TRACK" @@ -948,56 +949,33 @@ }, "action": { "type": "string", - "example": "CREATED" + "example": "UPDATED" }, - "eventId": { + "track": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/RemoteLibraryTrackDto" + } + ] + }, + "deletedTrackId": { + "type": "object", + "format": "uuid", + "nullable": true + }, + "createdAt": { "type": "string", - "example": "0" + "example": "2026-06-15T12:00:00.000Z" } }, "required": [ + "cursor", "entityType", "entityId", "action", - "eventId" - ] - }, - "SyncBootstrapResponseDto": { - "type": "object", - "properties": { - "nextCursor": { - "type": "string", - "example": "0" - }, - "tracks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LibraryTrackDto" - } - }, - "events": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SyncEventDto" - } - }, - "deletedTrackIds": { - "type": "array", - "items": { - "type": "string" - } - }, - "serverTime": { - "type": "string", - "example": "2026-05-24T20:00:00.000Z" - } - }, - "required": [ - "nextCursor", - "tracks", - "events", - "deletedTrackIds", - "serverTime" + "createdAt" ] }, "SyncChangesResponseDto": { @@ -1005,13 +983,20 @@ "properties": { "nextCursor": { "type": "string", - "example": "0" + "example": "7" }, - "tracks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LibraryTrackDto" - } + "hasMore": { + "type": "boolean", + "example": false + }, + "requiresBootstrap": { + "type": "boolean", + "example": false + }, + "reason": { + "type": "object", + "nullable": true, + "example": "cursor_too_old" }, "events": { "type": "array", @@ -1019,22 +1004,16 @@ "$ref": "#/components/schemas/SyncEventDto" } }, - "deletedTrackIds": { - "type": "array", - "items": { - "type": "string" - } - }, "serverTime": { "type": "string", - "example": "2026-05-24T20:00:00.000Z" + "example": "2026-06-15T12:00:00.000Z" } }, "required": [ "nextCursor", - "tracks", + "hasMore", + "requiresBootstrap", "events", - "deletedTrackIds", "serverTime" ] } diff --git a/backend/prisma/migrations/20260615100000_milestone102_incremental_sync_cursors/migration.sql b/backend/prisma/migrations/20260615100000_milestone102_incremental_sync_cursors/migration.sql new file mode 100644 index 0000000..b194290 --- /dev/null +++ b/backend/prisma/migrations/20260615100000_milestone102_incremental_sync_cursors/migration.sql @@ -0,0 +1,128 @@ +ALTER TABLE "users" +ADD COLUMN "library_cursor" BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE "library_events" +ADD COLUMN "cursor" BIGINT, +ADD COLUMN "payload" JSONB; + +WITH ranked_events AS ( + SELECT + "id", + ROW_NUMBER() OVER ( + PARTITION BY "user_id" + ORDER BY "created_at" ASC, "id" ASC + )::BIGINT AS next_cursor + FROM "library_events" +) +UPDATE "library_events" AS "event" +SET "cursor" = ranked_events.next_cursor +FROM ranked_events +WHERE ranked_events."id" = "event"."id"; + +UPDATE "library_events" AS "event" +SET "payload" = CASE + WHEN track."status" = 'DELETED'::"TrackStatus" THEN jsonb_build_object( + 'deletedTrackId', + track."id" + ) + WHEN audio_asset."id" IS NOT NULL THEN jsonb_build_object( + 'track', + jsonb_build_object( + 'trackId', + track."id", + 'title', + track."title", + 'artist', + track."artist", + 'durationSeconds', + GREATEST( + 0, + ROUND(COALESCE(track."duration_ms", audio_asset."duration_ms", 0)::numeric / 1000.0) + )::INTEGER, + 'sha256', + audio_asset."sha256", + 'assetId', + audio_asset."id", + 'createdAt', + to_char(track."created_at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + 'updatedAt', + to_char(track."updated_at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + 'artwork', + CASE + WHEN artwork_asset."id" IS NULL THEN NULL + ELSE jsonb_build_object( + 'artworkId', + artwork_asset."id", + 'sha256', + artwork_asset."sha256", + 'mimeType', + artwork_asset."mime_type", + 'width', + artwork_asset."width", + 'height', + artwork_asset."height" + ) + END + ) + ) + ELSE '{}'::jsonb +END +FROM "tracks" AS track +LEFT JOIN "audio_assets" AS audio_asset + ON audio_asset."id" = track."primary_audio_asset_id" +LEFT JOIN "artwork_assets" AS artwork_asset + ON artwork_asset."id" = track."artwork_asset_id" +WHERE "event"."entity_type" = 'TRACK'::"EntityType" + AND "event"."entity_id" = track."id"; + +UPDATE "library_events" +SET "payload" = '{}'::jsonb +WHERE "payload" IS NULL; + +ALTER TABLE "library_events" +ALTER COLUMN "cursor" SET NOT NULL, +ALTER COLUMN "payload" SET NOT NULL; + +CREATE UNIQUE INDEX "library_events_user_id_cursor_key" +ON "library_events"("user_id", "cursor"); + +CREATE INDEX "library_events_user_id_cursor_idx" +ON "library_events"("user_id", "cursor"); + +UPDATE "users" AS "user" +SET "library_cursor" = COALESCE(cursor_summary."max_cursor", 0) +FROM ( + SELECT + "user_id", + MAX("cursor") AS "max_cursor" + FROM "library_events" + GROUP BY "user_id" +) AS cursor_summary +WHERE cursor_summary."user_id" = "user"."id"; + +ALTER TABLE "device_sync_cursors" +ADD COLUMN "user_id" UUID, +ADD COLUMN "cursor" BIGINT NOT NULL DEFAULT 0; + +UPDATE "device_sync_cursors" AS "sync_cursor" +SET + "user_id" = device."user_id", + "cursor" = "sync_cursor"."last_event_id" +FROM "devices" AS device +WHERE device."id" = "sync_cursor"."device_id"; + +ALTER TABLE "device_sync_cursors" +ALTER COLUMN "user_id" SET NOT NULL; + +ALTER TABLE "device_sync_cursors" +DROP COLUMN "last_event_id", +DROP COLUMN "last_full_sync_at"; + +CREATE INDEX "device_sync_cursors_user_id_idx" +ON "device_sync_cursors"("user_id"); + +ALTER TABLE "device_sync_cursors" +ADD CONSTRAINT "device_sync_cursors_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8e5a67b..898696c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,6 +12,7 @@ model User { slug String @unique displayName String @map("display_name") isDefault Boolean @default(false) @map("is_default") + libraryCursor BigInt @default(0) @map("library_cursor") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") devices Device[] @@ -20,6 +21,7 @@ model User { artworkAssets ArtworkAsset[] uploadSessions UploadSession[] libraryEvents LibraryEvent[] + syncCursors DeviceSyncCursor[] @@map("users") } @@ -145,24 +147,30 @@ model UploadSession { model LibraryEvent { id BigInt @id @default(autoincrement()) userId String @db.Uuid @map("user_id") + cursor BigInt entityType EntityType @map("entity_type") entityId String @db.Uuid @map("entity_id") action EventAction + payload Json payloadVersion Int @default(1) @map("payload_version") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) @@index([userId]) + @@unique([userId, cursor]) + @@index([userId, cursor]) @@map("library_events") } model DeviceSyncCursor { - deviceId String @id @db.Uuid @map("device_id") - lastEventId BigInt @default(0) @map("last_event_id") - lastFullSyncAt DateTime? @map("last_full_sync_at") - updatedAt DateTime @updatedAt @map("updated_at") - device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + deviceId String @id @db.Uuid @map("device_id") + userId String @db.Uuid @map("user_id") + cursor BigInt @default(0) + updatedAt DateTime @updatedAt @map("updated_at") + device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@index([userId]) @@map("device_sync_cursors") } diff --git a/backend/src/modules/library/library.service.ts b/backend/src/modules/library/library.service.ts index 296ad8b..56c6131 100644 --- a/backend/src/modules/library/library.service.ts +++ b/backend/src/modules/library/library.service.ts @@ -41,6 +41,12 @@ export class LibraryService { const { userId: ownerUserId } = this.deviceAuthService.getAuthenticatedDeviceOrThrow(); + return this.getRemoteLibraryTracksForUser(ownerUserId); + } + + async getRemoteLibraryTracksForUser( + ownerUserId: string, + ): Promise { const tracks = await this.prismaService.track.findMany({ where: { userId: ownerUserId, diff --git a/backend/src/modules/sync/sync.controller.ts b/backend/src/modules/sync/sync.controller.ts index 2413a83..b40699e 100644 --- a/backend/src/modules/sync/sync.controller.ts +++ b/backend/src/modules/sync/sync.controller.ts @@ -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 { + async bootstrap(): Promise { return this.syncService.bootstrap(); } @@ -32,6 +29,6 @@ export class SyncController { async changes( @Query() query: SyncChangesQueryDto, ): Promise { - return this.syncService.changes(query.after ?? '0'); + return this.syncService.changes(query.cursor ?? '0', query.limit); } } diff --git a/backend/src/modules/sync/sync.dto.ts b/backend/src/modules/sync/sync.dto.ts index 0595f2c..68bdc11 100644 --- a/backend/src/modules/sync/sync.dto.ts +++ b/backend/src/modules/sync/sync.dto.ts @@ -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 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; +} diff --git a/backend/src/modules/sync/sync.service.spec.ts b/backend/src/modules/sync/sync.service.spec.ts index ac4517c..5a571c7 100644 --- a/backend/src/modules/sync/sync.service.spec.ts +++ b/backend/src/modules/sync/sync.service.spec.ts @@ -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; + 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', + }); + }); }); diff --git a/backend/src/modules/sync/sync.service.ts b/backend/src/modules/sync/sync.service.ts index 90cfc95..bbf20cd 100644 --- a/backend/src/modules/sync/sync.service.ts +++ b/backend/src/modules/sync/sync.service.ts @@ -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 { - 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 { - const latestCursor = await this.getLatestCursor(); - const normalizedCursor = - BigInt(latestCursor) > BigInt(after) ? latestCursor : after; + async changes( + cursor: string, + limit = DEFAULT_SYNC_PAGE_SIZE, + ): Promise { + 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 { - 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 { + 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 { + 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 { + 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; + 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, + }; } } diff --git a/backend/src/modules/uploads/uploads.service.spec.ts b/backend/src/modules/uploads/uploads.service.spec.ts index 41ad000..18b69ba 100644 --- a/backend/src/modules/uploads/uploads.service.spec.ts +++ b/backend/src/modules/uploads/uploads.service.spec.ts @@ -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); diff --git a/backend/src/modules/uploads/uploads.service.ts b/backend/src/modules/uploads/uploads.service.ts index 35960dd..205dc7a 100644 --- a/backend/src/modules/uploads/uploads.service.ts +++ b/backend/src/modules/uploads/uploads.service.ts @@ -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, + params: { + userId: string; + entityType: EntityType; + entityId: string; + action: EventAction; + payload: LibraryEventPayload; + }, + ): Promise { + 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 { diff --git a/backend/src/modules/users/default-user.service.spec.ts b/backend/src/modules/users/default-user.service.spec.ts index d6f7015..c3b3aee 100644 --- a/backend/src/modules/users/default-user.service.spec.ts +++ b/backend/src/modules/users/default-user.service.spec.ts @@ -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(), }); diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index e458d3b..29da39e 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -84,6 +84,7 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { function createPrismaMock() { const users = new Map(); const devices = new Map(); + const deviceSyncCursors = new Map(); const tracks = new Map(); const audioAssets = new Map(); const artworkAssets = new Map(); @@ -91,21 +92,81 @@ function createPrismaMock() { const libraryEvents = new Map(); let nextLibraryEventId = 1n; - const defaultUser = { + const createUserRecord = (data: Record) => { + const now = new Date(); + return { + id: data.id ?? randomUUID(), + createdAt: data.createdAt ?? now, + updatedAt: data.updatedAt ?? now, + ...data, + libraryCursor: + data.libraryCursor == null ? 0n : BigInt(data.libraryCursor), + }; + }; + + const defaultUser = createUserRecord({ id: randomUUID(), slug: 'default-owner', displayName: 'Default Owner', isDefault: true, - createdAt: new Date(), - updatedAt: new Date(), - }; + libraryCursor: 0n, + }); users.set(defaultUser.id, defaultUser); const prismaMock: any = { $queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]), $transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)), user: { - upsert: jest.fn().mockResolvedValue(defaultUser), + upsert: jest.fn().mockImplementation(async ({ where, update, create }) => { + const current = + [...users.values()].find((user) => user.slug === where.slug) ?? null; + + if (current) { + const updated = createUserRecord({ + ...current, + ...update, + id: current.id, + createdAt: current.createdAt, + updatedAt: new Date(), + libraryCursor: current.libraryCursor, + }); + users.set(updated.id, updated); + return updated; + } + + const created = createUserRecord(create); + users.set(created.id, created); + return created; + }), + create: jest.fn().mockImplementation(async ({ data }) => { + const created = createUserRecord(data); + users.set(created.id, created); + return created; + }), + update: jest.fn().mockImplementation(async ({ where, data, select }) => { + const current = users.get(where.id); + if (!current) { + throw new Error( + `Test Prisma mock invariant failed: user ${where.id} not found for update`, + ); + } + + const incrementBy = BigInt(data.libraryCursor?.increment ?? 0); + const updated = createUserRecord({ + ...current, + updatedAt: new Date(), + libraryCursor: current.libraryCursor + incrementBy, + }); + users.set(where.id, updated); + + if (select?.libraryCursor) { + return { + libraryCursor: updated.libraryCursor, + }; + } + + return updated; + }), }, device: { create: jest.fn().mockImplementation(async ({ data }) => { @@ -314,11 +375,44 @@ 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; + }), + findMany: jest.fn().mockImplementation(async ({ where, take }) => { + return [...libraryEvents.values()] + .filter( + (event) => + (where?.userId ? event.userId === where.userId : true) && + (where?.cursor?.gt != null ? event.cursor > where.cursor.gt : true), + ) + .sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor)) + .slice(0, take); + }), + }, + deviceSyncCursor: { + upsert: jest.fn().mockImplementation(async ({ where, update, create }) => { + const current = deviceSyncCursors.get(where.deviceId); + const nextRecord = current + ? { + ...current, + ...update, + updatedAt: new Date(), + } + : { + ...create, + updatedAt: new Date(), + }; + deviceSyncCursors.set(where.deviceId, nextRecord); + return nextRecord; }), }, }; @@ -327,7 +421,9 @@ function createPrismaMock() { prismaMock, state: { defaultUser, + users, devices, + deviceSyncCursors, tracks, audioAssets, artworkAssets, @@ -696,12 +792,20 @@ describe('Velody API wiring (e2e)', () => { syncController.bootstrap(), ); const changesResponse = await runAsDevice(device.deviceAccessToken, () => - syncController.changes({ after: '0' }), + syncController.changes({ cursor: '0' }), ); expect(bootstrapResponse.tracks).toEqual([]); + expect(bootstrapResponse.nextCursor).toBe('0'); expect(changesResponse.events).toEqual([]); expect(changesResponse.nextCursor).toBe('0'); + expect(changesResponse.hasMore).toBe(false); + expect(changesResponse.requiresBootstrap).toBe(false); + expect(prismaState.deviceSyncCursors.get(device.deviceId)).toMatchObject({ + deviceId: device.deviceId, + userId: prismaState.defaultUser.id, + cursor: 0n, + }); }); it('sync bootstrap and changes do not expose foreign-owner data', async () => { @@ -734,10 +838,24 @@ describe('Velody API wiring (e2e)', () => { }); prismaState.libraryEvents.set(1n, { id: 1n, + cursor: 1n, userId: foreignUserId, entityType: 'TRACK', entityId: foreignTrackId, action: 'CREATED', + payload: { + track: { + trackId: foreignTrackId, + title: 'Foreign Bootstrap Track', + artist: 'Elsewhere', + durationSeconds: 180, + sha256: 'f'.repeat(64), + assetId: randomUUID(), + createdAt: '2026-05-29T08:00:00.000Z', + updatedAt: '2026-05-29T08:01:00.000Z', + artwork: null, + }, + }, payloadVersion: 1, createdAt: new Date('2026-05-29T08:02:00.000Z'), }); @@ -746,7 +864,7 @@ describe('Velody API wiring (e2e)', () => { syncController.bootstrap(), ); const changesResponse = await runAsDevice(device.deviceAccessToken, () => - syncController.changes({ after: '0' }), + syncController.changes({ cursor: '0' }), ); expect(bootstrapResponse.tracks).toEqual([]); @@ -1590,6 +1708,18 @@ describe('Velody API wiring (e2e)', () => { it('makes an upload from one device visible to another linked device under the same owner', async () => { const identityUserId = randomUUID(); + prismaState.users.set( + identityUserId, + { + id: identityUserId, + slug: `identity-${identityUserId}`, + displayName: 'Identity Owner', + isDefault: false, + libraryCursor: 0n, + createdAt: new Date(), + updatedAt: new Date(), + }, + ); const primaryDevice = seedDevice({ userId: identityUserId, deviceAccessToken: 'linked-upload-primary-token', @@ -1713,7 +1843,7 @@ describe('Velody API wiring (e2e)', () => { expect(duplicatePrepare.status).toBe('exists'); expect(duplicatePrepare.uploadId).toBeDefined(); expect(prismaState.audioAssets.size).toBe(1); - expect(prismaState.libraryEvents.size).toBe(1); + expect(prismaState.libraryEvents.size).toBe(2); }); it('supports upload finalize with embedded artwork and exposes remote artwork metadata', async () => { diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift index 883c1d4..c945009 100644 --- a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift @@ -280,96 +280,157 @@ public struct SyncCursor: Codable, Hashable, Sendable { } public struct SyncEvent: Codable, Hashable, Sendable { + public var cursor: SyncCursor public var entityType: String public var entityId: String public var action: String - public var eventId: String + public var track: RemoteTrack? + public var deletedTrackId: String? + public var createdAt: String public init( + cursor: SyncCursor, entityType: String, entityId: String, action: String, - eventId: String + track: RemoteTrack? = nil, + deletedTrackId: String? = nil, + createdAt: String ) { + self.cursor = cursor self.entityType = entityType self.entityId = entityId self.action = action - self.eventId = eventId + self.track = track + self.deletedTrackId = deletedTrackId + self.createdAt = createdAt + } + + private enum CodingKeys: String, CodingKey { + case cursor + case entityType + case entityId + case action + case track + case deletedTrackId + case createdAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + cursor = SyncCursor( + value: try container.decode(String.self, forKey: .cursor) + ) + entityType = try container.decode(String.self, forKey: .entityType) + entityId = try container.decode(String.self, forKey: .entityId) + action = try container.decode(String.self, forKey: .action) + track = try container.decodeIfPresent(RemoteTrack.self, forKey: .track) + deletedTrackId = try container.decodeIfPresent(String.self, forKey: .deletedTrackId) + createdAt = try container.decode(String.self, forKey: .createdAt) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(cursor.value, forKey: .cursor) + try container.encode(entityType, forKey: .entityType) + try container.encode(entityId, forKey: .entityId) + try container.encode(action, forKey: .action) + try container.encodeIfPresent(track, forKey: .track) + try container.encodeIfPresent(deletedTrackId, forKey: .deletedTrackId) + try container.encode(createdAt, forKey: .createdAt) } } public struct SyncBootstrapResponse: Codable, Hashable, Sendable { public var nextCursor: SyncCursor - public var tracks: [LibraryTrack] - public var events: [SyncEvent] - public var deletedTrackIds: [String] + public var tracks: [RemoteTrack] public var serverTime: String public init( nextCursor: SyncCursor, - tracks: [LibraryTrack], - events: [SyncEvent], - deletedTrackIds: [String], + tracks: [RemoteTrack], serverTime: String ) { self.nextCursor = nextCursor self.tracks = tracks - self.events = events - self.deletedTrackIds = deletedTrackIds self.serverTime = serverTime } private enum CodingKeys: String, CodingKey { case nextCursor case tracks - case events - case deletedTrackIds case serverTime } - private struct WireTrack: Codable { - var id: String? - var title: String? - var artist: String? - } - public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let wireTracks = try container.decode([WireTrack].self, forKey: .tracks) - nextCursor = SyncCursor( value: try container.decode(String.self, forKey: .nextCursor) ) - tracks = wireTracks.map { track in - LibraryTrack( - id: track.id ?? UUID().uuidString, - title: track.title ?? "Unknown Title", - artist: track.artist ?? "Unknown Artist", - album: nil, - durationSeconds: nil, - localFilePath: "", - sha256: nil - ) - } - events = try container.decode([SyncEvent].self, forKey: .events) - deletedTrackIds = try container.decode([String].self, forKey: .deletedTrackIds) + tracks = try container.decode([RemoteTrack].self, forKey: .tracks) serverTime = try container.decode(String.self, forKey: .serverTime) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - let wireTracks = tracks.map { track in - WireTrack( - id: track.id, - title: track.title, - artist: track.artist - ) - } - try container.encode(nextCursor.value, forKey: .nextCursor) - try container.encode(wireTracks, forKey: .tracks) + try container.encode(tracks, forKey: .tracks) + try container.encode(serverTime, forKey: .serverTime) + } +} + +public struct SyncChangesResponse: Codable, Hashable, Sendable { + public var nextCursor: SyncCursor + public var hasMore: Bool + public var requiresBootstrap: Bool + public var reason: String? + public var events: [SyncEvent] + public var serverTime: String + + public init( + nextCursor: SyncCursor, + hasMore: Bool, + requiresBootstrap: Bool, + reason: String? = nil, + events: [SyncEvent], + serverTime: String + ) { + self.nextCursor = nextCursor + self.hasMore = hasMore + self.requiresBootstrap = requiresBootstrap + self.reason = reason + self.events = events + self.serverTime = serverTime + } + + private enum CodingKeys: String, CodingKey { + case nextCursor + case hasMore + case requiresBootstrap + case reason + case events + case serverTime + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + nextCursor = SyncCursor( + value: try container.decode(String.self, forKey: .nextCursor) + ) + hasMore = try container.decode(Bool.self, forKey: .hasMore) + requiresBootstrap = try container.decode(Bool.self, forKey: .requiresBootstrap) + reason = try container.decodeIfPresent(String.self, forKey: .reason) + events = try container.decode([SyncEvent].self, forKey: .events) + serverTime = try container.decode(String.self, forKey: .serverTime) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(nextCursor.value, forKey: .nextCursor) + try container.encode(hasMore, forKey: .hasMore) + try container.encode(requiresBootstrap, forKey: .requiresBootstrap) + try container.encodeIfPresent(reason, forKey: .reason) try container.encode(events, forKey: .events) - try container.encode(deletedTrackIds, forKey: .deletedTrackIds) try container.encode(serverTime, forKey: .serverTime) } } diff --git a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift index 6eeac2b..556bf68 100644 --- a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift +++ b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift @@ -39,6 +39,10 @@ public protocol VelodyAPIClient: Sendable { func fetchSyncBootstrap() async throws -> SyncBootstrapResponse + func fetchSyncChanges( + cursor: SyncCursor + ) async throws -> SyncChangesResponse + func fetchRemoteLibrary( deviceId: String ) async throws -> RemoteLibraryResponseDTO @@ -127,6 +131,20 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { ) } + public func fetchSyncChanges( + cursor: SyncCursor + ) async throws -> SyncChangesResponse { + try await sendRequest( + method: "GET", + pathComponents: ["api", "v1", "sync", "changes"], + queryItems: [ + URLQueryItem(name: "cursor", value: cursor.value), + ], + includesDeviceAuthorization: true, + responseType: SyncChangesResponse.self + ) + } + public func fetchRemoteLibrary( deviceId: String ) async throws -> RemoteLibraryResponseDTO { @@ -467,15 +485,31 @@ public struct StubVelodyAPIClient: VelodyAPIClient { return SyncBootstrapResponse( nextCursor: SyncCursor(value: "0"), tracks: [ - LibraryTrack( + RemoteTrack( + trackId: UUID().uuidString, title: "Velody Placeholder", artist: "Private Library", - album: "Phase 1", - localFilePath: "" + durationSeconds: 245, + sha256: String(repeating: "a", count: 64), + assetId: UUID().uuidString, + createdAt: ISO8601DateFormatter().string(from: .now), + updatedAt: ISO8601DateFormatter().string(from: .now) ), ], + serverTime: ISO8601DateFormatter().string(from: .now) + ) + } + + public func fetchSyncChanges( + cursor: SyncCursor + ) async throws -> SyncChangesResponse { + _ = cursor + + return SyncChangesResponse( + nextCursor: SyncCursor(value: "0"), + hasMore: false, + requiresBootstrap: false, events: [], - deletedTrackIds: [], serverTime: ISO8601DateFormatter().string(from: .now) ) } diff --git a/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift index d8c6f61..1ea0ebe 100644 --- a/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift +++ b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift @@ -77,4 +77,46 @@ final class RemoteLibraryDTOTests: XCTestCase { XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245) XCTAssertEqual(decoded.tracks.first?.artwork?.artworkId, "artwork-789") } + + func testSyncChangesResponseDecodesTrackPayloadAndCursor() throws { + let data = Data( + """ + { + "nextCursor": "12", + "hasMore": false, + "requiresBootstrap": false, + "events": [ + { + "cursor": "12", + "entityType": "TRACK", + "entityId": "track-123", + "action": "UPDATED", + "track": { + "trackId": "track-123", + "title": "Remote Title", + "artist": "Remote Artist", + "durationSeconds": 245, + "sha256": "\(String(repeating: "a", count: 64))", + "assetId": "asset-456", + "createdAt": "2026-06-15T12:00:00.000Z", + "updatedAt": "2026-06-15T12:05:00.000Z", + "artwork": null + }, + "deletedTrackId": null, + "createdAt": "2026-06-15T12:05:00.000Z" + } + ], + "serverTime": "2026-06-15T12:05:00.000Z" + } + """.utf8 + ) + + let decoded = try JSONDecoder().decode(SyncChangesResponse.self, from: data) + + XCTAssertEqual(decoded.nextCursor.value, "12") + XCTAssertFalse(decoded.hasMore) + XCTAssertFalse(decoded.requiresBootstrap) + XCTAssertEqual(decoded.events.first?.cursor.value, "12") + XCTAssertEqual(decoded.events.first?.track?.trackId, "track-123") + } } diff --git a/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/URLSessionVelodyAPIClientAuthorizationTests.swift b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/URLSessionVelodyAPIClientAuthorizationTests.swift index 6f7a7ec..b622918 100644 --- a/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/URLSessionVelodyAPIClientAuthorizationTests.swift +++ b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/URLSessionVelodyAPIClientAuthorizationTests.swift @@ -44,6 +44,52 @@ final class URLSessionVelodyAPIClientAuthorizationTests: XCTestCase { XCTAssertEqual(response.tracks.count, 0) } + func testFetchSyncChangesSendsAuthorizationHeaderAndCursorQuery() async throws { + RecordingURLProtocol.handler = { request in + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer test-device-access-token" + ) + XCTAssertEqual(request.url?.path, "/api/v1/sync/changes") + XCTAssertEqual(request.url?.query, "cursor=12") + + return ( + HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Data( + """ + { + "nextCursor": "12", + "hasMore": false, + "requiresBootstrap": false, + "events": [], + "serverTime": "2026-06-15T12:00:00.000Z" + } + """.utf8 + ) + ) + } + + let client = URLSessionVelodyAPIClient( + environment: ServerEnvironment( + baseURL: URL(string: "http://127.0.0.1:3007")!, + appVersion: "Tests" + ), + session: makeSession(), + deviceAccessTokenProvider: { + "test-device-access-token" + } + ) + + let response = try await client.fetchSyncChanges(cursor: SyncCursor(value: "12")) + XCTAssertEqual(response.nextCursor.value, "12") + XCTAssertEqual(response.events.count, 0) + } + func testRegisterDeviceDoesNotSendAuthorizationHeader() async throws { RecordingURLProtocol.handler = { request in XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteLibrarySyncCursorStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteLibrarySyncCursorStore.swift new file mode 100644 index 0000000..877978b --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteLibrarySyncCursorStore.swift @@ -0,0 +1,94 @@ +import Foundation +import VelodyDomain + +public protocol RemoteLibrarySyncCursorStore: Actor { + func loadCursor() async throws -> SyncCursor? + func saveCursor(_ cursor: SyncCursor) async throws + func clearCursor() async throws +} + +private struct StoredRemoteLibrarySyncCursor: Codable { + var value: String +} + +public actor FileRemoteLibrarySyncCursorStore: RemoteLibrarySyncCursorStore { + 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 loadCursor() async throws -> SyncCursor? { + guard fileManager.fileExists(atPath: fileURL.path) else { + return nil + } + + let data = try Data(contentsOf: fileURL) + let storedCursor = try decoder.decode(StoredRemoteLibrarySyncCursor.self, from: data) + return SyncCursor(value: storedCursor.value) + } + + public func saveCursor(_ cursor: SyncCursor) async throws { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let data = try encoder.encode( + StoredRemoteLibrarySyncCursor(value: cursor.value) + ) + try data.write(to: fileURL, options: .atomic) + } + + public func clearCursor() async throws { + guard fileManager.fileExists(atPath: fileURL.path) else { + return + } + + try fileManager.removeItem(at: fileURL) + } + + 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-sync-cursor.json") + } +} + +public actor InMemoryRemoteLibrarySyncCursorStore: RemoteLibrarySyncCursorStore { + private var cursor: SyncCursor? + + public init(cursor: SyncCursor? = nil) { + self.cursor = cursor + } + + public func loadCursor() async throws -> SyncCursor? { + cursor + } + + public func saveCursor(_ cursor: SyncCursor) async throws { + self.cursor = cursor + } + + public func clearCursor() async throws { + cursor = nil + } +} diff --git a/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift b/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift index 81e47c8..8eb19e1 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift @@ -37,7 +37,18 @@ public actor PlaceholderSyncCoordinator: SyncCoordinator { public func performInitialSync() async throws -> SyncResult { let bootstrap = try await apiClient.fetchSyncBootstrap() - try await store.replaceTracks(bootstrap.tracks) + try await store.replaceTracks( + bootstrap.tracks.map { track in + LibraryTrack( + id: track.trackId, + title: track.title, + artist: track.artist, + durationSeconds: Double(track.durationSeconds), + localFilePath: "", + sha256: track.sha256 + ) + } + ) let persistedTracks = try await store.loadTracks() return SyncResult( diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift index ee976bd..3fcf98b 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift @@ -13,13 +13,16 @@ public protocol RemoteLibraryRepository: Actor { public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository { private let apiClient: any VelodyAPIClient private let store: any RemoteLibraryStore + private let syncCursorStore: any RemoteLibrarySyncCursorStore public init( apiClient: any VelodyAPIClient, - store: any RemoteLibraryStore + store: any RemoteLibraryStore, + syncCursorStore: any RemoteLibrarySyncCursorStore ) { self.apiClient = apiClient self.store = store + self.syncCursorStore = syncCursorStore } public func loadCachedRemoteTracks() async throws -> [RemoteTrack] { @@ -27,10 +30,13 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository { } 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 + _ = deviceId + + if let currentCursor = try await syncCursorStore.loadCursor() { + return try await syncIncrementally(from: currentCursor) + } + + return try await bootstrap() } public func downloadAudioAsset( @@ -46,4 +52,74 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository { ) async throws -> Data { try await apiClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId) } + + private func bootstrap() async throws -> [RemoteTrack] { + let response = try await apiClient.fetchSyncBootstrap() + let tracks = orderTracks(response.tracks) + try await store.replaceRemoteTracks(tracks) + try await syncCursorStore.saveCursor(response.nextCursor) + return tracks + } + + private func syncIncrementally( + from cursor: SyncCursor + ) async throws -> [RemoteTrack] { + let cachedTracks = try await store.loadRemoteTracks() + var mergedTracks = Dictionary( + uniqueKeysWithValues: cachedTracks.map { ($0.trackId, $0) } + ) + var currentCursor = cursor + + while true { + let response = try await apiClient.fetchSyncChanges(cursor: currentCursor) + + if response.requiresBootstrap { + return try await bootstrap() + } + + mergedTracks = apply(events: response.events, to: mergedTracks) + currentCursor = response.nextCursor + + if !response.hasMore { + break + } + } + + let orderedTracks = orderTracks(Array(mergedTracks.values)) + try await store.replaceRemoteTracks(orderedTracks) + try await syncCursorStore.saveCursor(currentCursor) + return orderedTracks + } + + private func apply( + events: [SyncEvent], + to tracksByID: [String: RemoteTrack] + ) -> [String: RemoteTrack] { + var nextTracksByID = tracksByID + + for event in events { + if let deletedTrackID = event.deletedTrackId, !deletedTrackID.isEmpty { + nextTracksByID.removeValue(forKey: deletedTrackID) + continue + } + + guard let track = event.track else { + continue + } + + nextTracksByID[track.trackId] = track + } + + return nextTracksByID + } + + private func orderTracks(_ tracks: [RemoteTrack]) -> [RemoteTrack] { + tracks.sorted { lhs, rhs in + if lhs.createdAt == rhs.createdAt { + return lhs.trackId < rhs.trackId + } + + return lhs.createdAt < rhs.createdAt + } + } } diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift index 4576cba..a15a5c2 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift @@ -26,7 +26,7 @@ public actor RemoteLibrarySyncService { public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] { let states = try await downloadStateStore.loadDownloadStates() - return try await reconcileDownloadedLocalFilePaths(in: states) + return try await reconcilePersistedDownloadStates(in: states) } public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] { @@ -150,7 +150,7 @@ public actor RemoteLibrarySyncService { ) } - private func reconcileDownloadedLocalFilePaths( + private func reconcilePersistedDownloadStates( in states: [RemoteTrackDownloadState] ) async throws -> [RemoteTrackDownloadState] { guard !states.isEmpty else { @@ -162,19 +162,36 @@ public actor RemoteLibrarySyncService { for index in reconciledStates.indices { let state = reconciledStates[index] - guard state.downloadStatus == .downloaded else { - continue - } - - guard let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath( + let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath( persistedLocalFilePath: state.localFilePath, assetId: state.assetId - ) else { - continue - } + ) - if state.localFilePath != resolvedLocalFilePath { - reconciledStates[index].localFilePath = resolvedLocalFilePath + switch state.downloadStatus { + case .notDownloaded, .failed: + continue + case .downloaded: + guard let resolvedLocalFilePath else { + continue + } + + if state.localFilePath != resolvedLocalFilePath { + reconciledStates[index].localFilePath = resolvedLocalFilePath + didChange = true + } + case .downloading: + if let resolvedLocalFilePath { + if state.localFilePath != resolvedLocalFilePath { + reconciledStates[index].localFilePath = resolvedLocalFilePath + } + reconciledStates[index].downloadStatus = .downloaded + reconciledStates[index].lastDownloadError = nil + } else { + reconciledStates[index].localFilePath = "" + reconciledStates[index].downloadedAt = nil + reconciledStates[index].downloadStatus = .failed + reconciledStates[index].lastDownloadError = Self.interruptedDownloadErrorMessage + } didChange = true } } @@ -186,6 +203,8 @@ public actor RemoteLibrarySyncService { return reconciledStates } + private static let interruptedDownloadErrorMessage = "The previous download did not finish. Try again." + private func cacheArtwork( for tracks: [RemoteTrack], deviceId: String diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift index c3f6bc6..8a0158e 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift @@ -176,7 +176,8 @@ final class OfflineLibraryServiceTests: XCTestCase { let syncService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)), - store: remoteLibraryStore + store: remoteLibraryStore, + syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, @@ -226,8 +227,10 @@ final class OfflineLibraryServiceTests: XCTestCase { let remoteLibraryStore = InMemoryRemoteLibraryStore() let audioData = sampleMp3Data(seed: track.assetId) let apiClient = OfflineLibraryMockAPIClient( - remoteLibraryResponse: RemoteLibraryResponseDTO( - tracks: [makeRemoteTrackDTO(from: track)] + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "1"), + tracks: [track], + serverTime: "2026-05-30T08:00:00.000Z" ), audioAssetData: audioData, artworkDataByArtworkID: [ @@ -240,7 +243,8 @@ final class OfflineLibraryServiceTests: XCTestCase { let syncService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: apiClient, - store: remoteLibraryStore + store: remoteLibraryStore, + syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, @@ -284,8 +288,10 @@ final class OfflineLibraryServiceTests: XCTestCase { makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"), ] let apiClient = OfflineLibraryMockAPIClient( - remoteLibraryResponse: RemoteLibraryResponseDTO( - tracks: tracks.map { makeRemoteTrackDTO(from: $0) } + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "1"), + tracks: tracks, + serverTime: "2026-05-30T08:00:00.000Z" ), audioAssetDataByAssetID: [ "asset-1": sampleMp3Data(seed: "asset-1"), @@ -304,7 +310,8 @@ final class OfflineLibraryServiceTests: XCTestCase { let firstRepository = DefaultRemoteLibraryRepository( apiClient: apiClient, - store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL) + store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), + syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ) let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL) let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) @@ -329,7 +336,8 @@ final class OfflineLibraryServiceTests: XCTestCase { let relaunchedRepository = DefaultRemoteLibraryRepository( apiClient: apiClient, - store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL) + store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), + syncCursorStore: InMemoryRemoteLibrarySyncCursorStore(cursor: SyncCursor(value: "1")) ) let relaunchedDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL) let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) @@ -404,18 +412,22 @@ private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository { } private struct OfflineLibraryMockAPIClient: VelodyAPIClient { - let remoteLibraryResponse: RemoteLibraryResponseDTO? + let bootstrapResponse: SyncBootstrapResponse let audioAssetData: Data? let audioAssetDataByAssetID: [String: Data] let artworkDataByArtworkID: [String: Data] init( - remoteLibraryResponse: RemoteLibraryResponseDTO? = nil, + bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse( + nextCursor: SyncCursor(value: "0"), + tracks: [], + serverTime: "2026-05-30T08:00:00.000Z" + ), audioAssetData: Data? = nil, audioAssetDataByAssetID: [String: Data] = [:], artworkDataByArtworkID: [String: Data] = [:] ) { - self.remoteLibraryResponse = remoteLibraryResponse + self.bootstrapResponse = bootstrapResponse self.audioAssetData = audioAssetData self.audioAssetDataByAssetID = audioAssetDataByAssetID self.artworkDataByArtworkID = artworkDataByArtworkID @@ -444,11 +456,17 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient { } func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { - SyncBootstrapResponse( - nextCursor: SyncCursor(value: "0"), - tracks: [], + bootstrapResponse + } + + func fetchSyncChanges( + cursor: SyncCursor + ) async throws -> SyncChangesResponse { + SyncChangesResponse( + nextCursor: cursor, + hasMore: false, + requiresBootstrap: false, events: [], - deletedTrackIds: [], serverTime: "2026-05-30T08:00:00.000Z" ) } @@ -457,7 +475,9 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient { deviceId: String ) async throws -> RemoteLibraryResponseDTO { _ = deviceId - return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: []) + return RemoteLibraryResponseDTO( + tracks: bootstrapResponse.tracks.map { makeRemoteTrackDTO(from: $0) } + ) } func downloadAudioAsset( diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift index 086f332..0747dce 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift @@ -7,112 +7,277 @@ import VelodyPersistence @testable import VelodySync final class RemoteLibrarySyncServiceTests: XCTestCase { - func testSuccessfulSyncPersistsRemoteTracks() async throws { + func testBootstrapFirstSyncPersistsRemoteTracksAndCursor() async throws { let store = InMemoryRemoteLibraryStore() - let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() - 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 cursorStore = InMemoryRemoteLibrarySyncCursorStore() + let track = makeRemoteTrack(trackId: "track-123") + let service = makeSyncService( + apiClient: MockVelodyAPIClient( + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "4"), + tracks: [track], + serverTime: "2026-06-15T12:00:00.000Z" + ) ), - downloadStateStore: downloadStateStore, - audioFileStore: InMemoryOfflineAudioFileStore(), - artworkStore: InMemoryArtworkStore() + store: store, + cursorStore: cursorStore ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") let cachedTracks = try await service.loadCachedRemoteTracks() + let storedCursor = try await cursorStore.loadCursor() let downloadStates = try await service.loadDownloadStates() - XCTAssertEqual(tracks.count, 1) - XCTAssertEqual(cachedTracks, tracks) - XCTAssertEqual(cachedTracks.first?.trackId, "track-123") - XCTAssertEqual(downloadStates.first?.downloadStatus, .notDownloaded) + XCTAssertEqual(tracks, [track]) + XCTAssertEqual(cachedTracks, [track]) + XCTAssertEqual(storedCursor, SyncCursor(value: "4")) + XCTAssertEqual(downloadStates.first?.downloadStatus, RemoteTrackDownloadStatus.notDownloaded) } - 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" + func testChangesForExistingCursorApplyWithoutDuplicateTracks() async throws { + let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title") + let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "New Title") + let secondTrack = makeRemoteTrack( + trackId: "track-456", + title: "Second Track", + createdAt: "2026-06-15T12:01:00.000Z" + ) + let store = InMemoryRemoteLibraryStore(tracks: [originalTrack]) + let cursorStore = InMemoryRemoteLibrarySyncCursorStore( + cursor: SyncCursor(value: "4") + ) + let apiClient = MockVelodyAPIClient( + changeResponses: [ + SyncChangesResponse( + nextCursor: SyncCursor(value: "6"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "5"), + entityType: "TRACK", + entityId: updatedTrack.trackId, + action: "UPDATED", + track: updatedTrack, + createdAt: "2026-06-15T12:00:30.000Z" + ), + SyncEvent( + cursor: SyncCursor(value: "6"), + entityType: "AUDIO_ASSET", + entityId: secondTrack.assetId, + action: "CREATED", + track: secondTrack, + createdAt: "2026-06-15T12:01:00.000Z" + ), + ], + serverTime: "2026-06-15T12:01:00.000Z" ), ] ) - let downloadStateStore = InMemoryRemoteTrackDownloadStateStore( - states: [ - RemoteTrackDownloadState( - remoteTrackId: "track-123", - assetId: "asset-123", - downloadStatus: .downloaded - ), - ] - ) - let service = RemoteLibrarySyncService( - repository: DefaultRemoteLibraryRepository( - apiClient: MockVelodyAPIClient( - remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []) - ), - store: store - ), - downloadStateStore: downloadStateStore, - audioFileStore: InMemoryOfflineAudioFileStore(), - artworkStore: InMemoryArtworkStore() + let service = makeSyncService( + apiClient: apiClient, + store: store, + cursorStore: cursorStore ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") - let cachedTracks = try await service.loadCachedRemoteTracks() - let downloadStates = try await service.loadDownloadStates() + let storedCursor = try await cursorStore.loadCursor() - XCTAssertEqual(tracks, []) - XCTAssertEqual(cachedTracks, []) - XCTAssertEqual(downloadStates.count, 1) + XCTAssertEqual(tracks, [updatedTrack, secondTrack]) + XCTAssertEqual(Set(tracks.map(\.trackId)), ["track-123", "track-456"]) + XCTAssertEqual(storedCursor, SyncCursor(value: "6")) + let changeCursors = await apiClient.recordedChangeCursors() + XCTAssertEqual(changeCursors, ["4"]) } - 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" + func testPaginationAndReplayEventsDoNotDuplicateTracks() async throws { + let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title") + let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "Updated Once") + let secondUpdate = makeRemoteTrack(trackId: "track-123", title: "Updated Twice") + let secondTrack = makeRemoteTrack( + trackId: "track-456", + title: "Another Track", + createdAt: "2026-06-15T12:02:00.000Z" ) + let store = InMemoryRemoteLibraryStore(tracks: [originalTrack]) + let cursorStore = InMemoryRemoteLibrarySyncCursorStore( + cursor: SyncCursor(value: "3") + ) + let apiClient = MockVelodyAPIClient( + changeResponses: [ + SyncChangesResponse( + nextCursor: SyncCursor(value: "5"), + hasMore: true, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "4"), + entityType: "TRACK", + entityId: updatedTrack.trackId, + action: "UPDATED", + track: updatedTrack, + createdAt: "2026-06-15T12:00:30.000Z" + ), + SyncEvent( + cursor: SyncCursor(value: "5"), + entityType: "TRACK", + entityId: secondUpdate.trackId, + action: "UPDATED", + track: secondUpdate, + createdAt: "2026-06-15T12:01:00.000Z" + ), + ], + serverTime: "2026-06-15T12:01:00.000Z" + ), + SyncChangesResponse( + nextCursor: SyncCursor(value: "6"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "6"), + entityType: "TRACK", + entityId: secondTrack.trackId, + action: "CREATED", + track: secondTrack, + createdAt: "2026-06-15T12:02:00.000Z" + ), + ], + serverTime: "2026-06-15T12:02:00.000Z" + ), + ] + ) + let service = makeSyncService( + apiClient: apiClient, + store: store, + cursorStore: cursorStore + ) + + let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") + + XCTAssertEqual(tracks, [secondUpdate, secondTrack]) + XCTAssertEqual(Set(tracks.map(\.trackId)).count, 2) + let changeCursors = await apiClient.recordedChangeCursors() + XCTAssertEqual(changeCursors, ["3", "5"]) + } + + func testRequiresBootstrapFallbackReplacesCachedLibraryAndCursor() async throws { + let staleTrack = makeRemoteTrack(trackId: "track-stale", title: "Stale") + let freshTrack = makeRemoteTrack(trackId: "track-fresh", title: "Fresh") + let store = InMemoryRemoteLibraryStore(tracks: [staleTrack]) + let cursorStore = InMemoryRemoteLibrarySyncCursorStore( + cursor: SyncCursor(value: "2") + ) + let apiClient = MockVelodyAPIClient( + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "9"), + tracks: [freshTrack], + serverTime: "2026-06-15T12:05:00.000Z" + ), + changeResponses: [ + SyncChangesResponse( + nextCursor: SyncCursor(value: "2"), + hasMore: false, + requiresBootstrap: true, + reason: "cursor_too_old", + events: [], + serverTime: "2026-06-15T12:04:00.000Z" + ), + ] + ) + let service = makeSyncService( + apiClient: apiClient, + store: store, + cursorStore: cursorStore + ) + + let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") + let storedCursor = try await cursorStore.loadCursor() + + XCTAssertEqual(tracks, [freshTrack]) + XCTAssertEqual(storedCursor, SyncCursor(value: "9")) + let changeCursors = await apiClient.recordedChangeCursors() + XCTAssertEqual(changeCursors, ["2"]) + } + + func testCursorPersistsAcrossRepositoryInstances() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json") + let syncCursorFileURL = tempDirectory.appendingPathComponent("remote-library-sync-cursor.json") + let bootstrapTrack = makeRemoteTrack(trackId: "track-bootstrap") + let changedTrack = makeRemoteTrack(trackId: "track-bootstrap", title: "Track Bootstrap Updated") + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let firstClient = MockVelodyAPIClient( + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "2"), + tracks: [bootstrapTrack], + serverTime: "2026-06-15T12:00:00.000Z" + ) + ) + let firstService = makeSyncService( + apiClient: firstClient, + store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), + cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) + ) + + _ = try await firstService.syncRemoteLibrary(deviceId: "device-123") + + let secondClient = MockVelodyAPIClient( + changeResponses: [ + SyncChangesResponse( + nextCursor: SyncCursor(value: "3"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "3"), + entityType: "TRACK", + entityId: changedTrack.trackId, + action: "UPDATED", + track: changedTrack, + createdAt: "2026-06-15T12:01:00.000Z" + ), + ], + serverTime: "2026-06-15T12:01:00.000Z" + ), + ] + ) + let secondService = makeSyncService( + apiClient: secondClient, + store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), + cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) + ) + + let tracks = try await secondService.syncRemoteLibrary(deviceId: "device-123") + let storedCursor = try await FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) + .loadCursor() + + XCTAssertEqual(tracks, [changedTrack]) + XCTAssertEqual(storedCursor, SyncCursor(value: "3")) + let changeCursors = await secondClient.recordedChangeCursors() + XCTAssertEqual(changeCursors, ["2"]) + } + + func testNetworkFailureLeavesCachedLibraryAndCursorIntact() async throws { + let cachedTrack = makeRemoteTrack(trackId: "track-123", title: "Cached") let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack]) - let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() - let service = RemoteLibrarySyncService( - repository: DefaultRemoteLibraryRepository( - apiClient: MockVelodyAPIClient( - remoteLibraryError: VelodyAPIError.requestFailed("Offline") - ), - store: store + let cursorStore = InMemoryRemoteLibrarySyncCursorStore( + cursor: SyncCursor(value: "7") + ) + let service = makeSyncService( + apiClient: MockVelodyAPIClient( + changeError: VelodyAPIError.requestFailed("Offline") ), - downloadStateStore: downloadStateStore, - audioFileStore: InMemoryOfflineAudioFileStore(), - artworkStore: InMemoryArtworkStore() + store: store, + cursorStore: cursorStore ) await XCTAssertThrowsErrorAsync { @@ -120,7 +285,48 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { } let cachedTracks = try await service.loadCachedRemoteTracks() + let storedCursor = try await cursorStore.loadCursor() XCTAssertEqual(cachedTracks, [cachedTrack]) + XCTAssertEqual(storedCursor, SyncCursor(value: "7")) + } + + func testSyncFailurePreservesDownloadedStateAndLocalFile() async throws { + let track = makeRemoteTrack(trackId: "track-offline", assetId: "asset-offline") + let localFilePath = "/in-memory/\(track.assetId).mp3" + let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [ + RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + localFilePath: localFilePath, + downloadedAt: Date(timeIntervalSince1970: 3_000), + downloadStatus: .downloaded + ), + ]) + let audioFileStore = InMemoryOfflineAudioFileStore(files: [ + localFilePath: sampleMp3Data(seed: "network-safe"), + ]) + let service = makeSyncService( + apiClient: MockVelodyAPIClient( + changeError: VelodyAPIError.requestFailed("Offline") + ), + store: InMemoryRemoteLibraryStore(tracks: [track]), + cursorStore: InMemoryRemoteLibrarySyncCursorStore( + cursor: SyncCursor(value: "7") + ), + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore + ) + + await XCTAssertThrowsErrorAsync { + _ = try await service.syncRemoteLibrary(deviceId: "device-123") + } + + let recoveredState = try await service.loadDownloadStates().first + let fileExists = await audioFileStore.fileExists(at: localFilePath) + + XCTAssertEqual(recoveredState?.downloadStatus, .downloaded) + XCTAssertEqual(recoveredState?.localFilePath, localFilePath) + XCTAssertTrue(fileExists) } func testDownloadTrackPersistsDownloadedStateAndFile() async throws { @@ -129,34 +335,29 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( - remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []), audioAssetData: sampleMp3Data(seed: "download-success") ), - store: InMemoryRemoteLibraryStore() + store: InMemoryRemoteLibraryStore(), + syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: InMemoryArtworkStore() ) - let track = RemoteTrack( + let track = makeRemoteTrack( trackId: "track-123", - title: "Remote Title", - artist: "Remote Artist", - durationSeconds: 245, sha256: sha256Hex(sampleMp3Data(seed: "download-success")), - assetId: "asset-456", - createdAt: "2026-05-29T08:00:00.000Z", - updatedAt: "2026-05-29T08:05:00.000Z" + assetId: "asset-456" ) let state = try await service.downloadTrack(track, deviceId: "device-123") let storedStates = try await service.loadDownloadStates() let fileExists = await audioFileStore.fileExists(at: state.localFilePath) - XCTAssertEqual(state.downloadStatus, .downloaded) + XCTAssertEqual(state.downloadStatus, RemoteTrackDownloadStatus.downloaded) XCTAssertEqual(state.assetId, "asset-456") XCTAssertFalse(state.localFilePath.isEmpty) - XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded) + XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.downloaded) XCTAssertTrue(fileExists) } @@ -164,35 +365,129 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( - remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []), downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing") ), - store: InMemoryRemoteLibraryStore() + store: InMemoryRemoteLibraryStore(), + syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: InMemoryRemoteTrackDownloadStateStore(), audioFileStore: InMemoryOfflineAudioFileStore(), artworkStore: InMemoryArtworkStore() ) - let track = RemoteTrack( - trackId: "track-123", - title: "Remote Title", - artist: "Remote Artist", - durationSeconds: 245, - sha256: sha256Hex(sampleMp3Data(seed: "download-failure")), - assetId: "asset-456", - createdAt: "2026-05-29T08:00:00.000Z", - updatedAt: "2026-05-29T08:05:00.000Z" - ) + let track = makeRemoteTrack(trackId: "track-123", assetId: "asset-456") await XCTAssertThrowsErrorAsync { _ = try await service.downloadTrack(track, deviceId: "device-123") } let storedStates = try await service.loadDownloadStates() - XCTAssertEqual(storedStates.first?.downloadStatus, .failed) + XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.failed) XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123") } + func testRetryAfterFailureCanSucceedAndPersistDownloadedState() async throws { + let audioData = sampleMp3Data(seed: "retry-success") + let track = makeRemoteTrack( + trackId: "track-retry", + sha256: sha256Hex(audioData), + assetId: "asset-retry" + ) + let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() + let audioFileStore = InMemoryOfflineAudioFileStore() + let service = RemoteLibrarySyncService( + repository: SequencedDownloadRepository( + downloadResults: [ + .failure(VelodyAPIError.server(statusCode: 503, message: "Try Again")), + .success(audioData), + ] + ), + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore, + artworkStore: InMemoryArtworkStore() + ) + + await XCTAssertThrowsErrorAsync { + _ = try await service.downloadTrack(track, deviceId: "device-123") + } + + let failedState = try await service.loadDownloadStates().first + XCTAssertEqual(failedState?.downloadStatus, .failed) + + let recoveredState = try await service.downloadTrack(track, deviceId: "device-123") + let persistedState = try await downloadStateStore.loadDownloadStates().first + let fileExists = await audioFileStore.fileExists(at: recoveredState.localFilePath) + + XCTAssertEqual(recoveredState.downloadStatus, .downloaded) + XCTAssertEqual(persistedState?.downloadStatus, .downloaded) + XCTAssertFalse(recoveredState.localFilePath.isEmpty) + XCTAssertTrue(fileExists) + } + + func testLoadDownloadStatesRecoversInterruptedDownloadToFailedRetryStateWhenFileIsMissing() async throws { + let interruptedState = RemoteTrackDownloadState( + remoteTrackId: "track-123", + assetId: "asset-456", + localFilePath: "/in-memory/asset-456.mp3", + downloadedAt: Date(timeIntervalSince1970: 1_000), + downloadStatus: .downloading + ) + let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState]) + let service = makeSyncService( + apiClient: MockVelodyAPIClient(), + store: InMemoryRemoteLibraryStore(), + cursorStore: InMemoryRemoteLibrarySyncCursorStore(), + downloadStateStore: downloadStateStore, + audioFileStore: InMemoryOfflineAudioFileStore() + ) + + let recoveredStates = try await service.loadDownloadStates() + let recoveredState = try XCTUnwrap(recoveredStates.first) + let persistedState = try await downloadStateStore.loadDownloadStates().first + + XCTAssertEqual(recoveredState.downloadStatus, .failed) + XCTAssertEqual( + recoveredState.lastDownloadError, + "The previous download did not finish. Try again." + ) + XCTAssertEqual(recoveredState.localFilePath, "") + XCTAssertNil(recoveredState.downloadedAt) + XCTAssertEqual(persistedState, recoveredState) + } + + func testLoadDownloadStatesRecoversInterruptedDownloadToDownloadedWhenFileExists() async throws { + let recoveredFilePath = "/in-memory/asset-456.mp3" + let recoveredDate = Date(timeIntervalSince1970: 2_000) + let interruptedState = RemoteTrackDownloadState( + remoteTrackId: "track-123", + assetId: "asset-456", + localFilePath: "", + downloadedAt: recoveredDate, + downloadStatus: .downloading, + lastDownloadError: "Interrupted" + ) + let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState]) + let audioFileStore = InMemoryOfflineAudioFileStore(files: [ + recoveredFilePath: sampleMp3Data(seed: "interrupted-file"), + ]) + let service = makeSyncService( + apiClient: MockVelodyAPIClient(), + store: InMemoryRemoteLibraryStore(), + cursorStore: InMemoryRemoteLibrarySyncCursorStore(), + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore + ) + + let recoveredStates = try await service.loadDownloadStates() + let recoveredState = try XCTUnwrap(recoveredStates.first) + let persistedState = try await downloadStateStore.loadDownloadStates().first + + XCTAssertEqual(recoveredState.downloadStatus, .downloaded) + XCTAssertEqual(recoveredState.localFilePath, recoveredFilePath) + XCTAssertEqual(recoveredState.downloadedAt, recoveredDate) + XCTAssertNil(recoveredState.lastDownloadError) + XCTAssertEqual(persistedState, recoveredState) + } + func testLoadDownloadStatesRepairsStaleLocalFilePathAfterStoreRecreation() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( @@ -203,15 +498,11 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true) let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json") let audioData = sampleMp3Data(seed: "relaunch-repair") - let track = RemoteTrack( + let track = makeRemoteTrack( trackId: "track-123", title: "1 Mai 2026", - artist: "Remote Artist", - durationSeconds: 245, sha256: sha256Hex(audioData), - assetId: "asset-456", - createdAt: "2026-05-29T08:00:00.000Z", - updatedAt: "2026-05-29T08:05:00.000Z" + assetId: "asset-456" ) defer { @@ -220,11 +511,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { let firstService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( - apiClient: MockVelodyAPIClient( - remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []), - audioAssetData: audioData - ), - store: InMemoryRemoteLibraryStore() + apiClient: MockVelodyAPIClient(audioAssetData: audioData), + store: InMemoryRemoteLibraryStore(), + syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL), audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory), @@ -240,8 +529,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory) let relaunchedService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( - apiClient: MockVelodyAPIClient(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])), - store: InMemoryRemoteLibraryStore() + apiClient: MockVelodyAPIClient(), + store: InMemoryRemoteLibraryStore(), + syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL), audioFileStore: relaunchedAudioStore, @@ -255,7 +545,7 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { .loadDownloadStates() .first - XCTAssertEqual(restoredState.downloadStatus, .downloaded) + XCTAssertEqual(restoredState.downloadStatus, RemoteTrackDownloadStatus.downloaded) XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path) XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path) XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath)) @@ -270,37 +560,22 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { width: 1, height: 1 ) + let track = makeRemoteTrack( + trackId: "track-123", + artwork: artwork + ) let artworkStore = InMemoryArtworkStore() - 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", - artwork: RemoteArtworkDTO( - artworkId: artwork.artworkId, - sha256: artwork.sha256, - mimeType: artwork.mimeType, - width: artwork.width, - height: artwork.height - ) - ), - ] - ), - artworkData: sampleArtworkData() + let service = makeSyncService( + apiClient: MockVelodyAPIClient( + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "1"), + tracks: [track], + serverTime: "2026-06-15T12:00:00.000Z" ), - store: InMemoryRemoteLibraryStore() + artworkData: sampleArtworkData() ), - downloadStateStore: InMemoryRemoteTrackDownloadStateStore(), - audioFileStore: InMemoryOfflineAudioFileStore(), + store: InMemoryRemoteLibraryStore(), + cursorStore: InMemoryRemoteLibrarySyncCursorStore(), artworkStore: artworkStore ) @@ -315,24 +590,53 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { } } -private struct MockVelodyAPIClient: VelodyAPIClient { - let remoteLibraryResponse: RemoteLibraryResponseDTO? - let remoteLibraryError: VelodyAPIError? - let audioAssetData: Data? - let downloadError: VelodyAPIError? - let artworkData: Data? - let artworkDownloadError: VelodyAPIError? +private func makeSyncService( + apiClient: any VelodyAPIClient, + store: any RemoteLibraryStore, + cursorStore: any RemoteLibrarySyncCursorStore, + downloadStateStore: any RemoteTrackDownloadStateStore = InMemoryRemoteTrackDownloadStateStore(), + audioFileStore: any OfflineAudioFileStore = InMemoryOfflineAudioFileStore(), + artworkStore: any ArtworkStore = InMemoryArtworkStore() +) -> RemoteLibrarySyncService { + RemoteLibrarySyncService( + repository: DefaultRemoteLibraryRepository( + apiClient: apiClient, + store: store, + syncCursorStore: cursorStore + ), + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore, + artworkStore: artworkStore + ) +} + +private actor MockVelodyAPIClient: VelodyAPIClient { + private let bootstrapResponse: SyncBootstrapResponse + private let changeResponses: [SyncChangesResponse] + private let changeError: VelodyAPIError? + private let audioAssetData: Data? + private let downloadError: VelodyAPIError? + private let artworkData: Data? + private let artworkDownloadError: VelodyAPIError? + private var changeResponseIndex = 0 + private var changeCursors: [String] = [] init( - remoteLibraryResponse: RemoteLibraryResponseDTO? = nil, - remoteLibraryError: VelodyAPIError? = nil, + bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse( + nextCursor: SyncCursor(value: "0"), + tracks: [], + serverTime: "2026-06-15T12:00:00.000Z" + ), + changeResponses: [SyncChangesResponse] = [], + changeError: VelodyAPIError? = nil, audioAssetData: Data? = nil, downloadError: VelodyAPIError? = nil, artworkData: Data? = nil, artworkDownloadError: VelodyAPIError? = nil ) { - self.remoteLibraryResponse = remoteLibraryResponse - self.remoteLibraryError = remoteLibraryError + self.bootstrapResponse = bootstrapResponse + self.changeResponses = changeResponses + self.changeError = changeError self.audioAssetData = audioAssetData self.downloadError = downloadError self.artworkData = artworkData @@ -347,7 +651,7 @@ private struct MockVelodyAPIClient: VelodyAPIClient { deviceId: UUID().uuidString, deviceAccessToken: UUID().uuidString, bootstrapToken: UUID().uuidString, - serverTime: "2026-05-29T08:00:00.000Z" + serverTime: "2026-06-15T12:00:00.000Z" ) } @@ -357,30 +661,43 @@ private struct MockVelodyAPIClient: VelodyAPIClient { _ = payload return DeviceHeartbeatResponse( ok: true, - serverTime: "2026-05-29T08:00:00.000Z" + serverTime: "2026-06-15T12:00:00.000Z" ) } func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { - SyncBootstrapResponse( - nextCursor: SyncCursor(value: "0"), - tracks: [], - events: [], - deletedTrackIds: [], - serverTime: "2026-05-29T08:00:00.000Z" - ) + bootstrapResponse + } + + func fetchSyncChanges( + cursor: SyncCursor + ) async throws -> SyncChangesResponse { + changeCursors.append(cursor.value) + + if let changeError { + throw changeError + } + + guard !changeResponses.isEmpty else { + return SyncChangesResponse( + nextCursor: cursor, + hasMore: false, + requiresBootstrap: false, + events: [], + serverTime: "2026-06-15T12:00:00.000Z" + ) + } + + let response = changeResponses[min(changeResponseIndex, changeResponses.count - 1)] + changeResponseIndex += 1 + return response } func fetchRemoteLibrary( deviceId: String ) async throws -> RemoteLibraryResponseDTO { _ = deviceId - - if let remoteLibraryError { - throw remoteLibraryError - } - - return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: []) + return RemoteLibraryResponseDTO(tracks: []) } func downloadAudioAsset( @@ -457,6 +774,79 @@ private struct MockVelodyAPIClient: VelodyAPIClient { assetId: UUID().uuidString ) } + + func recordedChangeCursors() -> [String] { + changeCursors + } +} + +private actor SequencedDownloadRepository: RemoteLibraryRepository { + private var downloadResults: [Result] + private let tracks: [RemoteTrack] + + init( + downloadResults: [Result], + tracks: [RemoteTrack] = [] + ) { + self.downloadResults = downloadResults + self.tracks = tracks + } + + func loadCachedRemoteTracks() async throws -> [RemoteTrack] { + tracks + } + + func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] { + _ = deviceId + return tracks + } + + func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data { + _ = assetId + _ = deviceId + + guard !downloadResults.isEmpty else { + return Data() + } + + let result = downloadResults.removeFirst() + switch result { + case let .success(data): + return data + case let .failure(error): + throw error + } + } + + func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data { + _ = artworkId + _ = deviceId + return Data() + } +} + +private func makeRemoteTrack( + trackId: String, + title: String = "Remote Title", + artist: String = "Remote Artist", + durationSeconds: Int = 245, + sha256: String = String(repeating: "a", count: 64), + assetId: String? = nil, + createdAt: String = "2026-06-15T12:00:00.000Z", + updatedAt: String = "2026-06-15T12:05:00.000Z", + artwork: RemoteArtwork? = nil +) -> RemoteTrack { + RemoteTrack( + trackId: trackId, + title: title, + artist: artist, + durationSeconds: durationSeconds, + sha256: sha256, + assetId: assetId ?? "asset-\(trackId)", + createdAt: createdAt, + updatedAt: updatedAt, + artwork: artwork + ) } private func sampleMp3Data(seed: String) -> Data {