From 18ed79e3c43ca79d49a73badab93971229514558 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 31 May 2026 01:20:56 +0200 Subject: [PATCH] Fix artwork upload pipeline --- .../VelodyMac/Sources/LocalMusicScanner.swift | 39 ++- .../Sources/MacLibraryViewModel.swift | 101 ++++-- .../migration.sql | 23 ++ backend/prisma/schema.prisma | 11 +- backend/src/app.factory.spec.ts | 73 +++++ backend/src/app.factory.ts | 17 +- .../modules/artwork/artwork.service.spec.ts | 21 +- .../src/modules/artwork/artwork.service.ts | 13 +- .../src/modules/storage/storage.service.ts | 18 ++ backend/src/modules/uploads/uploads.dto.ts | 45 +++ .../modules/uploads/uploads.service.spec.ts | 148 +++++++++ .../src/modules/uploads/uploads.service.ts | 200 +++++++++++- backend/test/e2e/app.e2e-spec.ts | 300 +++++++++++++++++- .../Sources/VelodyDomain/Models.swift | 52 ++- .../VelodyPersistence/LocalArtworkStore.swift | 158 +++++++++ .../LocalCatalogService.swift | 2 + .../LocalMusicDiscovery.swift | 27 +- ...ocalTrackArtworkUploadPayloadBuilder.swift | 27 ++ .../VelodyPersistence/LocalTrackDTOs.swift | 9 + .../MP3EmbeddedArtworkExtractor.swift | 192 +++++++++++ .../VelodyPersistence/TrackEntity.swift | 33 ++ .../LocalCatalogServiceTests.swift | 36 +++ ...rackArtworkUploadPayloadBuilderTests.swift | 50 +++ .../MP3EmbeddedArtworkExtractorTests.swift | 113 +++++++ 24 files changed, 1652 insertions(+), 56 deletions(-) create mode 100644 backend/prisma/migrations/20260530093000_milestone81_artwork_upload_pipeline_fix/migration.sql create mode 100644 backend/src/app.factory.spec.ts create mode 100644 packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalArtworkStore.swift create mode 100644 packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackArtworkUploadPayloadBuilder.swift create mode 100644 packages/apple/VelodyPersistence/Sources/VelodyPersistence/MP3EmbeddedArtworkExtractor.swift create mode 100644 packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalTrackArtworkUploadPayloadBuilderTests.swift create mode 100644 packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/MP3EmbeddedArtworkExtractorTests.swift diff --git a/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift b/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift index a73642f..1198807 100644 --- a/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift +++ b/apps/apple/VelodyMac/Sources/LocalMusicScanner.swift @@ -1,34 +1,46 @@ import AVFoundation import CryptoKit import Foundation +import VelodyDomain import VelodyPersistence final class AVFoundationMetadataReader: MetadataReader { + private let artworkExtractor: MP3EmbeddedArtworkExtractor + + init(artworkExtractor: MP3EmbeddedArtworkExtractor = MP3EmbeddedArtworkExtractor()) { + self.artworkExtractor = artworkExtractor + } + func readMetadata(for fileURL: URL) async throws -> LocalTrackMetadata { let asset = AVURLAsset(url: fileURL) let commonMetadata = try await asset.load(.commonMetadata) let duration = try? await asset.load(.duration) + let artwork = try? artworkExtractor.extractArtwork(from: fileURL) return LocalTrackMetadata( title: commonMetadata.firstStringValue(for: .commonIdentifierTitle), artist: commonMetadata.firstStringValue(for: .commonIdentifierArtist), album: commonMetadata.firstStringValue(for: .commonIdentifierAlbumName), - durationSeconds: duration?.seconds.validDurationSeconds + durationSeconds: duration?.seconds.validDurationSeconds, + artwork: artwork ) } } final class FileSystemLocalMusicScanner: LocalMusicScanner { private let metadataReader: any MetadataReader + private let artworkStore: any LocalArtworkStore private let fileHasher: SHA256FileHasher private let fileManager: FileManager init( metadataReader: any MetadataReader, + artworkStore: any LocalArtworkStore, fileHasher: SHA256FileHasher = SHA256FileHasher(), fileManager: FileManager = .default ) { self.metadataReader = metadataReader + self.artworkStore = artworkStore self.fileHasher = fileHasher self.fileManager = fileManager } @@ -65,6 +77,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner { do { let metadata = try await metadataReader.readMetadata(for: fileURL) + let artwork = try await persistedArtwork(from: metadata.artwork) discoveredTracks.append( ScannedLocalTrack( title: metadata.title?.trimmedNonEmpty ?? fallbackTitle, @@ -73,6 +86,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner { durationSeconds: metadata.durationSeconds, localFilePath: fileURL.path, sha256: sha256, + artwork: artwork, fileModifiedAt: fileModifiedAt ) ) @@ -85,6 +99,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner { durationSeconds: nil, localFilePath: fileURL.path, sha256: sha256, + artwork: nil, fileModifiedAt: fileModifiedAt ) ) @@ -125,6 +140,28 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner { return .distantPast } + + private func persistedArtwork( + from artwork: LocalTrackMetadata.EmbeddedArtworkPayload? + ) async throws -> LocalTrackArtwork? { + guard let artwork else { + return nil + } + + let localFilePath = try await artworkStore.saveArtwork( + artwork.data, + sha256: artwork.sha256, + mimeType: artwork.mimeType + ) + + return LocalTrackArtwork( + localFilePath: localFilePath, + sha256: artwork.sha256, + mimeType: artwork.mimeType, + width: artwork.width, + height: artwork.height + ) + } } final class SHA256FileHasher { diff --git a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift index 0b34765..b5fdde3 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift @@ -37,6 +37,8 @@ final class MacLibraryViewModel { private let catalogService: any LocalCatalogService private let trackRepository: any TrackRepository private let localMusicScanner: any LocalMusicScanner + private let localArtworkStore: any LocalArtworkStore + private let artworkUploadPayloadBuilder: LocalTrackArtworkUploadPayloadBuilder private let playbackController: PlaybackController private let keychainService: any KeychainService private let userDefaults: UserDefaults @@ -49,8 +51,10 @@ final class MacLibraryViewModel { fileManager: FileManager = .default ) { let folderAccessService = FolderAccessService() + let localArtworkStore = Self.makeLocalArtworkStore() let localMusicScanner = FileSystemLocalMusicScanner( - metadataReader: AVFoundationMetadataReader() + metadataReader: AVFoundationMetadataReader(), + artworkStore: localArtworkStore ) let repository = Self.makeTrackRepository() let playbackController = PlaybackController( @@ -64,6 +68,10 @@ final class MacLibraryViewModel { self.catalogService = DefaultLocalCatalogService(repository: repository) self.trackRepository = repository self.localMusicScanner = localMusicScanner + self.localArtworkStore = localArtworkStore + self.artworkUploadPayloadBuilder = LocalTrackArtworkUploadPayloadBuilder( + artworkStore: localArtworkStore + ) self.playbackController = playbackController self.keychainService = keychainService self.userDefaults = userDefaults @@ -450,6 +458,35 @@ final class MacLibraryViewModel { switch prepareResponse.status { case .exists: + if let uploadId = prepareResponse.uploadId, + let track = currentTrack(for: trackID), + track.artwork != nil + { + try await setTrackUploadState( + trackID: trackID, + status: .uploading, + remoteTrackId: prepareResponse.trackId ?? track.remoteTrackId, + lastUploadError: nil, + progress: 0.7 + ) + + let finalizeResponse = try await finalizePreparedUpload( + trackID: trackID, + uploadId: uploadId, + apiClient: apiClient + ) + + try await setTrackUploadState( + trackID: trackID, + status: .uploaded, + remoteTrackId: finalizeResponse.trackId, + lastUploadError: nil, + progress: 1 + ) + lastUploadStatus = "Updated remote artwork for \(track.title) on track \(finalizeResponse.trackId)." + return .success(remoteTrackId: finalizeResponse.trackId) + } + let remoteTrackID = prepareResponse.trackId ?? currentTrack(for: trackID)?.remoteTrackId try await setTrackUploadState( trackID: trackID, @@ -487,26 +524,10 @@ final class MacLibraryViewModel { throw UploadPipelineError.uploadDidNotComplete(uploadResponse.status.rawValue) } - try await setTrackUploadState( + let finalizeResponse = try await finalizePreparedUpload( trackID: trackID, - status: .uploading, - remoteTrackId: currentTrack(for: trackID)?.remoteTrackId, - lastUploadError: nil, - progress: 0.85 - ) - - guard let track = currentTrack(for: trackID) else { - throw UploadPipelineError.trackMissing - } - - let finalizeResponse = try await apiClient.finalizeUpload( uploadId: uploadId, - payload: UploadFinalizeRequest( - title: track.title, - artist: track.artist, - album: track.album, - durationMs: durationMilliseconds(from: track.durationSeconds) - ) + apiClient: apiClient ) try await setTrackUploadState( @@ -516,7 +537,8 @@ final class MacLibraryViewModel { lastUploadError: nil, progress: 1 ) - lastUploadStatus = "Uploaded \(track.title) as remote track \(finalizeResponse.trackId)." + let uploadedTrackTitle = currentTrack(for: trackID)?.title ?? initialTrack.title + lastUploadStatus = "Uploaded \(uploadedTrackTitle) as remote track \(finalizeResponse.trackId)." return .success(remoteTrackId: finalizeResponse.trackId) } } catch { @@ -533,6 +555,37 @@ final class MacLibraryViewModel { } } + private func finalizePreparedUpload( + trackID: String, + uploadId: String, + apiClient: any VelodyAPIClient + ) async throws -> UploadFinalizeResponse { + try await setTrackUploadState( + trackID: trackID, + status: .uploading, + remoteTrackId: currentTrack(for: trackID)?.remoteTrackId, + lastUploadError: nil, + progress: 0.85 + ) + + guard let track = currentTrack(for: trackID) else { + throw UploadPipelineError.trackMissing + } + + return try await apiClient.finalizeUpload( + uploadId: uploadId, + payload: UploadFinalizeRequest( + title: track.title, + artist: track.artist, + album: track.album, + durationMs: durationMilliseconds(from: track.durationSeconds), + artwork: try await artworkUploadPayloadBuilder.makePayload( + for: track.artwork + ) + ) + ) + } + private func restoreDeviceIdentity() async { do { let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey) @@ -734,6 +787,14 @@ final class MacLibraryViewModel { return InMemoryTrackRepository() } + private static func makeLocalArtworkStore() -> any LocalArtworkStore { + if let store = try? FileLocalArtworkStore() { + return store + } + + return InMemoryLocalArtworkStore() + } + private static func scanStatus( for result: LocalCatalogScanResult, activeTrackCount: Int diff --git a/backend/prisma/migrations/20260530093000_milestone81_artwork_upload_pipeline_fix/migration.sql b/backend/prisma/migrations/20260530093000_milestone81_artwork_upload_pipeline_fix/migration.sql new file mode 100644 index 0000000..3d01359 --- /dev/null +++ b/backend/prisma/migrations/20260530093000_milestone81_artwork_upload_pipeline_fix/migration.sql @@ -0,0 +1,23 @@ +ALTER TABLE "artwork_assets" +ADD COLUMN "user_id" UUID; + +UPDATE "artwork_assets" AS "aa" +SET "user_id" = "t"."user_id" +FROM "tracks" AS "t" +WHERE "t"."artwork_asset_id" = "aa"."id" + AND "aa"."user_id" IS NULL; + +ALTER TABLE "artwork_assets" +ADD CONSTRAINT "artwork_assets_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; + +DROP INDEX IF EXISTS "tracks_artwork_asset_id_key"; +DROP INDEX IF EXISTS "artwork_assets_sha256_key"; + +CREATE INDEX "artwork_assets_user_id_idx" +ON "artwork_assets"("user_id"); + +CREATE UNIQUE INDEX "artwork_assets_user_id_sha256_key" +ON "artwork_assets"("user_id", "sha256"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5b00ecb..82f7dbc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -17,6 +17,7 @@ model User { devices Device[] tracks Track[] audioAssets AudioAsset[] + artworkAssets ArtworkAsset[] uploadSessions UploadSession[] libraryEvents LibraryEvent[] @@ -46,7 +47,7 @@ model Track { id String @id @default(uuid()) @db.Uuid userId String @db.Uuid @map("user_id") primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_id") - artworkAssetId String? @unique @db.Uuid @map("artwork_asset_id") + artworkAssetId String? @db.Uuid @map("artwork_asset_id") title String artist String album String? @@ -96,16 +97,20 @@ model AudioAsset { } model ArtworkAsset { + userId String? @db.Uuid @map("user_id") id String @id @default(uuid()) @db.Uuid - sha256 String @unique + sha256 String storageKey String @unique @map("storage_key") mimeType String @map("mime_type") width Int? height Int? fileSizeBytes BigInt @map("file_size_bytes") createdAt DateTime @default(now()) @map("created_at") - track Track? @relation("TrackArtwork") + tracks Track[] @relation("TrackArtwork") + user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + @@unique([userId, sha256]) + @@index([userId]) @@map("artwork_assets") } diff --git a/backend/src/app.factory.spec.ts b/backend/src/app.factory.spec.ts new file mode 100644 index 0000000..9c63dbd --- /dev/null +++ b/backend/src/app.factory.spec.ts @@ -0,0 +1,73 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; + +const setTitle = jest.fn().mockReturnThis(); +const setDescription = jest.fn().mockReturnThis(); +const setVersion = jest.fn().mockReturnThis(); +const build = jest.fn().mockReturnValue({}); +const createDocument = jest.fn().mockReturnValue({}); +const setup = jest.fn(); + +jest.mock('@nestjs/core', () => ({ + NestFactory: { + create: jest.fn(), + }, +})); + +jest.mock('@nestjs/swagger', () => { + const actual = jest.requireActual('@nestjs/swagger'); + return { + ...actual, + DocumentBuilder: jest.fn().mockImplementation(() => ({ + setTitle, + setDescription, + setVersion, + build, + })), + SwaggerModule: { + ...actual.SwaggerModule, + createDocument, + setup, + }, + }; +}); + +import { API_JSON_BODY_LIMIT, createApp } from './app.factory'; + +describe('createApp', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('registers a larger JSON body parser for artwork finalize payloads', async () => { + const app = { + useBodyParser: jest.fn(), + setGlobalPrefix: jest.fn(), + enableVersioning: jest.fn(), + useGlobalPipes: jest.fn(), + } as unknown as NestExpressApplication; + + (NestFactory.create as jest.Mock).mockResolvedValue(app); + + await createApp(); + + expect(NestFactory.create).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + bufferLogs: true, + bodyParser: false, + }), + ); + expect((app as any).useBodyParser).toHaveBeenNthCalledWith(1, 'json', { + limit: API_JSON_BODY_LIMIT, + }); + expect((app as any).useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { + extended: true, + limit: API_JSON_BODY_LIMIT, + }); + expect((app as any).useGlobalPipes).toHaveBeenCalledWith( + expect.any(ValidationPipe), + ); + }); +}); diff --git a/backend/src/app.factory.ts b/backend/src/app.factory.ts index 06a0a0a..4ddeed5 100644 --- a/backend/src/app.factory.ts +++ b/backend/src/app.factory.ts @@ -1,10 +1,23 @@ import { ValidationPipe, VersioningType } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; -export async function createApp() { - const app = await NestFactory.create(AppModule, { bufferLogs: true }); +export const API_JSON_BODY_LIMIT = '2mb'; + +export async function createApp(): Promise { + const app = await NestFactory.create(AppModule, { + bufferLogs: true, + bodyParser: false, + }); + + // Upload finalize requests can include base64 artwork payloads. + app.useBodyParser('json', { limit: API_JSON_BODY_LIMIT }); + app.useBodyParser('urlencoded', { + extended: true, + limit: API_JSON_BODY_LIMIT, + }); app.setGlobalPrefix('api'); app.enableVersioning({ diff --git a/backend/src/modules/artwork/artwork.service.spec.ts b/backend/src/modules/artwork/artwork.service.spec.ts index 217add7..3586949 100644 --- a/backend/src/modules/artwork/artwork.service.spec.ts +++ b/backend/src/modules/artwork/artwork.service.spec.ts @@ -63,7 +63,7 @@ describe('ArtworkService', () => { const userId = randomUUID(); const deviceId = randomUUID(); const artworkId = randomUUID(); - const storageKey = join('library', 'artwork', `${artworkId}.png`); + const storageKey = join('users', userId, 'artwork', `${artworkId}.png`); const bytes = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==', 'base64', @@ -71,11 +71,10 @@ describe('ArtworkService', () => { state.devices.set(deviceId, { id: deviceId, userId }); state.artworkAssets.set(artworkId, { + userId, storageKey, mimeType: 'image/png', - track: { - userId, - }, + tracks: [{ userId }], }); const filePath = storageService.resolve(storageKey); @@ -97,11 +96,10 @@ describe('ArtworkService', () => { state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId }); state.artworkAssets.set(artworkId, { - storageKey: join('library', 'artwork', `${artworkId}.jpg`), + userId: ownerId, + storageKey: join('users', ownerId, 'artwork', `${artworkId}.jpg`), mimeType: 'image/jpeg', - track: { - userId: ownerId, - }, + tracks: [{ userId: ownerId }], }); await expect( @@ -116,11 +114,10 @@ describe('ArtworkService', () => { state.devices.set(deviceId, { id: deviceId, userId }); state.artworkAssets.set(artworkId, { - storageKey: join('library', 'artwork', `${artworkId}.png`), + userId, + storageKey: join('users', userId, 'artwork', `${artworkId}.png`), mimeType: 'image/png', - track: { - userId, - }, + tracks: [{ userId }], }); await expect( diff --git a/backend/src/modules/artwork/artwork.service.ts b/backend/src/modules/artwork/artwork.service.ts index 2bcdba3..417c3d2 100644 --- a/backend/src/modules/artwork/artwork.service.ts +++ b/backend/src/modules/artwork/artwork.service.ts @@ -38,9 +38,11 @@ export class ArtworkService { const artwork = await this.prismaService.artworkAsset.findUnique({ where: { id: artworkId }, select: { + userId: true, storageKey: true, mimeType: true, - track: { + tracks: { + take: 1, select: { userId: true, }, @@ -48,11 +50,16 @@ export class ArtworkService { }, }); - if (!artwork || !artwork.track) { + if (!artwork) { throw new NotFoundException('Artwork not found'); } - if (artwork.track.userId !== device.userId) { + const ownerUserId = artwork.userId ?? artwork.tracks[0]?.userId; + if (!ownerUserId) { + throw new NotFoundException('Artwork not found'); + } + + if (ownerUserId !== device.userId) { throw new ForbiddenException('Artwork does not belong to this device user.'); } diff --git a/backend/src/modules/storage/storage.service.ts b/backend/src/modules/storage/storage.service.ts index b600d2d..2c2f4ab 100644 --- a/backend/src/modules/storage/storage.service.ts +++ b/backend/src/modules/storage/storage.service.ts @@ -29,6 +29,24 @@ export class LocalFilesystemStorageService { return this.resolve(this.userAudioAssetStorageKey(userId, sha256)); } + userArtworkAssetStorageKey( + userId: string, + sha256: string, + fileExtension: string, + ): string { + return join('users', userId, 'artwork', `${sha256}.${fileExtension}`); + } + + userArtworkAssetPath( + userId: string, + sha256: string, + fileExtension: string, + ): string { + return this.resolve( + this.userArtworkAssetStorageKey(userId, sha256, fileExtension), + ); + } + tempUploadStorageKey(uploadId: string): string { return join('temp', 'uploads', `${uploadId}.part`); } diff --git a/backend/src/modules/uploads/uploads.dto.ts b/backend/src/modules/uploads/uploads.dto.ts index a52c73b..e3c8078 100644 --- a/backend/src/modules/uploads/uploads.dto.ts +++ b/backend/src/modules/uploads/uploads.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { UploadSessionStatus } from '@prisma/client'; +import { Type } from 'class-transformer'; import { IsInt, IsOptional, @@ -9,6 +10,7 @@ import { Max, Min, MinLength, + ValidateNested, } from 'class-validator'; export class UploadPrepareRequestDto { @@ -72,7 +74,41 @@ export class UploadSessionStatusResponseDto { finalizedAt?: string; } +export class UploadFinalizeArtworkDto { + @ApiProperty({ example: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ...' }) + @IsString() + @MinLength(1) + dataBase64!: string; + + @ApiProperty({ + example: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }) + @Matches(/^[a-f0-9]{64}$/) + sha256!: string; + + @ApiProperty({ enum: ['image/jpeg', 'image/png'] }) + @Matches(/^image\/(jpeg|png)$/) + mimeType!: string; + + @ApiProperty({ required: false, example: 512 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + width?: number; + + @ApiProperty({ required: false, example: 512 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(Number.MAX_SAFE_INTEGER) + height?: number; +} + export class UploadFinalizeRequestDto { + static readonly artworkMimeTypes = ['image/jpeg', 'image/png'] as const; + @ApiProperty({ example: 'Track Title' }) @IsString() @MinLength(1) @@ -94,6 +130,15 @@ export class UploadFinalizeRequestDto { @Min(1) @Max(Number.MAX_SAFE_INTEGER) durationMs?: number; + + @ApiProperty({ + required: false, + type: () => UploadFinalizeArtworkDto, + }) + @IsOptional() + @ValidateNested() + @Type(() => UploadFinalizeArtworkDto) + artwork?: UploadFinalizeArtworkDto; } export class UploadFinalizeResponseDto { diff --git a/backend/src/modules/uploads/uploads.service.spec.ts b/backend/src/modules/uploads/uploads.service.spec.ts index d8e1ae9..0558921 100644 --- a/backend/src/modules/uploads/uploads.service.spec.ts +++ b/backend/src/modules/uploads/uploads.service.spec.ts @@ -16,6 +16,7 @@ function createPrismaMock() { const devices = new Map(); const tracks = new Map(); const audioAssets = new Map(); + const artworkAssets = new Map(); const uploadSessions = new Map(); const libraryEvents = new Map(); let nextLibraryEventId = 1n; @@ -145,6 +146,35 @@ function createPrismaMock() { return updated; }), }, + artworkAsset: { + findFirst: jest.fn().mockImplementation(async ({ where }) => { + return ( + [...artworkAssets.values()].find( + (asset) => + asset.userId === where.userId && + asset.sha256 === where.sha256, + ) ?? null + ); + }), + create: jest.fn().mockImplementation(async ({ data }) => { + const record = { + id: randomUUID(), + createdAt: new Date(), + ...data, + }; + artworkAssets.set(record.id, record); + return record; + }), + update: jest.fn().mockImplementation(async ({ where, data }) => { + const current = artworkAssets.get(where.id); + const updated = { + ...current, + ...data, + }; + artworkAssets.set(where.id, updated); + return updated; + }), + }, uploadSession: { create: jest.fn().mockImplementation(async ({ data }) => { const now = new Date(); @@ -199,6 +229,7 @@ function createPrismaMock() { devices, tracks, audioAssets, + artworkAssets, uploadSessions, libraryEvents, }, @@ -238,6 +269,13 @@ function sha256Hex(data: Buffer): string { return createHash('sha256').update(data).digest('hex'); } +function sampleArtworkBytes(): Buffer { + return Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==', + 'base64', + ); +} + describe('UploadsService', () => { let prismaMock: any; let state: MockState; @@ -369,6 +407,50 @@ describe('UploadsService', () => { expect(session.audioAssetId).toBe(finalizeResponse.assetId); }); + it('finalize with embedded artwork creates and links an artwork asset', async () => { + const device = seedDevice(); + const uploadedBytes = sampleMp3Bytes('finalize-with-artwork'); + const artworkBytes = sampleArtworkBytes(); + const sha256 = sha256Hex(uploadedBytes); + const artworkSHA256 = sha256Hex(artworkBytes); + const response = await service.prepare({ + deviceId: device.id, + sha256, + originalFilename: 'finalize-with-artwork.mp3', + sizeBytes: uploadedBytes.length, + }); + + await service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes)); + const finalizeResponse = await service.finalize(response.uploadId!, { + title: 'Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 245000, + artwork: { + dataBase64: artworkBytes.toString('base64'), + sha256: artworkSHA256, + mimeType: 'image/png', + width: 1, + height: 1, + }, + }); + + expect(finalizeResponse.trackId).toBeDefined(); + expect(state.artworkAssets.size).toBe(1); + + const track = [...state.tracks.values()][0]; + const artworkAsset = [...state.artworkAssets.values()][0]; + expect(track.artworkAssetId).toBe(artworkAsset.id); + expect(artworkAsset.userId).toBe(state.defaultUser.id); + expect(artworkAsset.sha256).toBe(artworkSHA256); + expect(artworkAsset.mimeType).toBe('image/png'); + + const storedBytes = await readFile( + join(storageRoot, 'users', state.defaultUser.id, 'artwork', `${artworkSHA256}.png`), + ); + expect(storedBytes.equals(artworkBytes)).toBe(true); + }); + it('returns exists from prepare after a successful upload and finalize', async () => { const device = seedDevice(); const uploadedBytes = sampleMp3Bytes('duplicate-handling'); @@ -399,7 +481,73 @@ describe('UploadsService', () => { }); expect(secondPrepare.status).toBe('exists'); + expect(secondPrepare.uploadId).toBeDefined(); expect(secondPrepare.trackId).toBe(finalizeResponse.trackId); expect(secondPrepare.assetId).toBe(finalizeResponse.assetId); }); + + it('finalize on an exists prepare attaches artwork to an existing audio asset without re-uploading bytes', async () => { + const device = seedDevice(); + const uploadedBytes = sampleMp3Bytes('duplicate-artwork-update'); + const artworkBytes = sampleArtworkBytes(); + const sha256 = sha256Hex(uploadedBytes); + const artworkSHA256 = sha256Hex(artworkBytes); + + const firstPrepare = await service.prepare({ + deviceId: device.id, + sha256, + originalFilename: 'duplicate-artwork-update.mp3', + sizeBytes: uploadedBytes.length, + }); + + await service.uploadFile( + firstPrepare.uploadId!, + createUploadRequest(uploadedBytes), + ); + const firstFinalize = await service.finalize(firstPrepare.uploadId!, { + title: 'Duplicate Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 123000, + }); + + const secondPrepare = await service.prepare({ + deviceId: device.id, + sha256, + originalFilename: 'duplicate-artwork-update.mp3', + sizeBytes: uploadedBytes.length, + }); + + expect(secondPrepare.status).toBe('exists'); + expect(secondPrepare.uploadId).toBeDefined(); + + const secondFinalize = await service.finalize(secondPrepare.uploadId!, { + title: 'Duplicate Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 123000, + artwork: { + dataBase64: artworkBytes.toString('base64'), + sha256: artworkSHA256, + mimeType: 'image/png', + width: 1, + height: 1, + }, + }); + + expect(secondFinalize.trackId).toBe(firstFinalize.trackId); + expect(secondFinalize.assetId).toBe(firstFinalize.assetId); + expect(state.audioAssets.size).toBe(1); + expect(state.artworkAssets.size).toBe(1); + + const track = [...state.tracks.values()][0]; + const artworkAsset = [...state.artworkAssets.values()][0]; + expect(track.artworkAssetId).toBe(artworkAsset.id); + expect(artworkAsset.sha256).toBe(artworkSHA256); + + const storedArtworkBytes = await readFile( + join(storageRoot, 'users', state.defaultUser.id, 'artwork', `${artworkSHA256}.png`), + ); + expect(storedArtworkBytes.equals(artworkBytes)).toBe(true); + }); }); diff --git a/backend/src/modules/uploads/uploads.service.ts b/backend/src/modules/uploads/uploads.service.ts index 43091cc..168d136 100644 --- a/backend/src/modules/uploads/uploads.service.ts +++ b/backend/src/modules/uploads/uploads.service.ts @@ -12,12 +12,13 @@ import { import type { Request } from 'express'; import { createHash, randomUUID } from 'node:crypto'; import { constants } from 'node:fs'; -import { access, open, rename, unlink } from 'node:fs/promises'; +import { access, open, rename, unlink, writeFile } from 'node:fs/promises'; import { extname } from 'node:path'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { AppConfigService } from '../config/config.service'; import { LocalFilesystemStorageService } from '../storage/storage.service'; import { + UploadFinalizeArtworkDto, UploadFinalizeRequestDto, UploadFinalizeResponseDto, UploadPrepareRequestDto, @@ -25,6 +26,15 @@ import { UploadSessionStatusResponseDto, } from './uploads.dto'; +interface PreparedArtworkAssetInput { + sha256: string; + mimeType: string; + width?: number; + height?: number; + storageKey: string; + fileSizeBytes: bigint; +} + @Injectable() export class UploadsService { constructor( @@ -57,8 +67,28 @@ export class UploadsService { }); if (existingAsset) { + const uploadId = randomUUID(); + const uploadSession = await this.prismaService.uploadSession.create({ + data: { + id: uploadId, + userId: device.userId, + deviceId: body.deviceId, + trackId: existingAsset.trackId, + audioAssetId: existingAsset.id, + expectedSha256: body.sha256, + originalFilename: body.originalFilename, + expectedSizeBytes: BigInt(body.sizeBytes), + receivedBytes: BigInt(body.sizeBytes), + tempStoragePath: this.storageService.tempUploadStorageKey(uploadId), + status: UploadSessionStatus.COMPLETED, + completedAt: new Date(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + return { status: 'exists', + uploadId: uploadSession.id, trackId: existingAsset.trackId ?? undefined, assetId: existingAsset.id, }; @@ -276,6 +306,13 @@ export class UploadsService { ); } + const preparedArtwork = body.artwork + ? await this.prepareArtworkAssetInput( + currentSession.userId, + body.artwork, + ) + : null; + let audioAsset = await tx.audioAsset.findUnique({ where: { userId_sha256: { @@ -361,6 +398,25 @@ export class UploadsService { }); } + const artworkAssetId = preparedArtwork + ? ( + await this.findOrCreateArtworkAsset( + tx, + currentSession.userId, + preparedArtwork, + ) + ).id + : null; + + if ((track.artworkAssetId ?? null) !== artworkAssetId) { + track = await tx.track.update({ + where: { id: track.id }, + data: { + artworkAssetId, + }, + }); + } + await tx.libraryEvent.create({ data: { userId: currentSession.userId, @@ -423,6 +479,94 @@ export class UploadsService { } } + private async prepareArtworkAssetInput( + userId: string, + artwork: UploadFinalizeArtworkDto, + ): Promise { + const mimeType = this.normalizeArtworkMimeType(artwork.mimeType); + const artworkBytes = this.decodeArtworkData(artwork.dataBase64); + const actualSha256 = createHash('sha256').update(artworkBytes).digest('hex'); + + if (actualSha256 !== artwork.sha256) { + throw new UnprocessableEntityException( + 'Artwork hash does not match the provided artwork sha256.', + ); + } + + const fileExtension = this.artworkFileExtension(mimeType); + const storageKey = this.storageService.userArtworkAssetStorageKey( + userId, + artwork.sha256, + fileExtension, + ); + const filePath = this.storageService.resolve(storageKey); + + await this.storageService.ensureParentDirectory(filePath); + if (!(await this.fileExists(filePath))) { + await this.writeFileAtomically(filePath, artworkBytes); + } + + return { + sha256: artwork.sha256, + mimeType, + width: artwork.width, + height: artwork.height, + storageKey, + fileSizeBytes: BigInt(artworkBytes.length), + }; + } + + private async findOrCreateArtworkAsset( + tx: Pick, + userId: string, + artwork: PreparedArtworkAssetInput, + ) { + let artworkAsset = await tx.artworkAsset.findFirst({ + where: { + userId, + sha256: artwork.sha256, + }, + }); + + if (artworkAsset) { + const shouldUpdateArtworkAsset = + artworkAsset.mimeType !== artwork.mimeType || + artworkAsset.width !== artwork.width || + artworkAsset.height !== artwork.height || + artworkAsset.storageKey !== artwork.storageKey || + artworkAsset.fileSizeBytes !== artwork.fileSizeBytes || + artworkAsset.userId !== userId; + + if (shouldUpdateArtworkAsset) { + artworkAsset = await tx.artworkAsset.update({ + where: { id: artworkAsset.id }, + data: { + userId, + mimeType: artwork.mimeType, + width: artwork.width, + height: artwork.height, + storageKey: artwork.storageKey, + fileSizeBytes: artwork.fileSizeBytes, + }, + }); + } + + return artworkAsset; + } + + return tx.artworkAsset.create({ + data: { + userId, + sha256: artwork.sha256, + mimeType: artwork.mimeType, + width: artwork.width, + height: artwork.height, + storageKey: artwork.storageKey, + fileSizeBytes: artwork.fileSizeBytes, + }, + }); + } + private assertMp3Filename(filename: string): void { if (extname(filename).toLowerCase() !== '.mp3') { throw new UnprocessableEntityException('Only MP3 uploads are supported.'); @@ -507,6 +651,45 @@ export class UploadsService { ); } + private decodeArtworkData(dataBase64: string): Buffer { + const artworkBytes = Buffer.from(dataBase64, 'base64'); + + if (artworkBytes.length === 0) { + throw new UnprocessableEntityException( + 'Artwork data must contain a non-empty base64 image payload.', + ); + } + + return artworkBytes; + } + + private normalizeArtworkMimeType(mimeType: string): string { + switch (mimeType.trim().toLowerCase()) { + case 'image/jpeg': + case 'image/jpg': + return 'image/jpeg'; + case 'image/png': + return 'image/png'; + default: + throw new UnprocessableEntityException( + 'Only embedded JPEG and PNG artwork are supported.', + ); + } + } + + private artworkFileExtension(mimeType: string): string { + switch (mimeType) { + case 'image/jpeg': + return 'jpg'; + case 'image/png': + return 'png'; + default: + throw new UnprocessableEntityException( + 'Only embedded JPEG and PNG artwork are supported.', + ); + } + } + private async markUploadFailed( uploadId: string, receivedBytes: number, @@ -537,6 +720,21 @@ export class UploadsService { } } + private async writeFileAtomically( + path: string, + data: Buffer, + ): Promise { + const tempPath = `${path}.${randomUUID()}.tmp`; + + try { + await writeFile(tempPath, data); + await rename(tempPath, path); + } catch (error) { + await this.safeUnlink(tempPath); + throw error; + } + } + private trimOptional(value?: string): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index de1c9f0..9b5386c 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -5,12 +5,13 @@ import { dirname, join } from 'node:path'; import { Readable } from 'node:stream'; import { ForbiddenException, - INestApplication, NotFoundException, ValidationPipe, VersioningType, } from '@nestjs/common'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import { Test } from '@nestjs/testing'; +import { API_JSON_BODY_LIMIT } from '../../src/app.factory'; import { AppModule } from '../../src/app.module'; import { AssetsController } from '../../src/modules/assets/assets.controller'; import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto'; @@ -218,6 +219,33 @@ function createPrismaMock() { findUnique: jest.fn().mockImplementation(async ({ where }) => { return artworkAssets.get(where.id) ?? null; }), + findFirst: jest.fn().mockImplementation(async ({ where }) => { + return ( + [...artworkAssets.values()].find( + (asset) => + asset.userId === where.userId && + asset.sha256 === where.sha256, + ) ?? null + ); + }), + create: jest.fn().mockImplementation(async ({ data }) => { + const record = { + id: randomUUID(), + createdAt: new Date(), + ...data, + }; + artworkAssets.set(record.id, record); + return record; + }), + update: jest.fn().mockImplementation(async ({ where, data }) => { + const current = artworkAssets.get(where.id); + const updated = { + ...current, + ...data, + }; + artworkAssets.set(where.id, updated); + return updated; + }), }, uploadSession: { create: jest.fn().mockImplementation(async ({ data }) => { @@ -284,7 +312,7 @@ function createPrismaMock() { } describe('Velody API wiring (e2e)', () => { - let app: INestApplication; + let app: NestExpressApplication; let assetsController: AssetsController; let artworkController: ArtworkController; let healthController: HealthController; @@ -314,7 +342,12 @@ describe('Velody API wiring (e2e)', () => { .useValue(prismaMock) .compile(); - app = moduleRef.createNestApplication(); + app = moduleRef.createNestApplication({ bodyParser: false }); + app.useBodyParser('json', { limit: API_JSON_BODY_LIMIT }); + app.useBodyParser('urlencoded', { + extended: true, + limit: API_JSON_BODY_LIMIT, + }); app.setGlobalPrefix('api'); app.enableVersioning({ type: VersioningType.URI }); app.useGlobalPipes( @@ -515,10 +548,16 @@ describe('Velody API wiring (e2e)', () => { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==', 'base64', ); - const storageKey = join('library', 'artwork', `${artworkId}.png`); + const storageKey = join( + 'users', + prismaState.defaultUser.id, + 'artwork', + `${sha256Hex(bytes)}.png`, + ); prismaState.artworkAssets.set(artworkId, { id: artworkId, + userId: prismaState.defaultUser.id, sha256: sha256Hex(bytes), storageKey, mimeType: 'image/png', @@ -526,9 +565,7 @@ describe('Velody API wiring (e2e)', () => { height: 1, fileSizeBytes: BigInt(bytes.length), createdAt: new Date('2026-05-29T08:00:00.000Z'), - track: { - userId: prismaState.defaultUser.id, - }, + tracks: [{ userId: prismaState.defaultUser.id }], }); prismaState.tracks.set(trackId, { id: trackId, @@ -583,16 +620,20 @@ describe('Velody API wiring (e2e)', () => { prismaState.artworkAssets.set(artworkId, { id: artworkId, + userId: prismaState.defaultUser.id, sha256: 'sha-missing-artwork', - storageKey: join('library', 'artwork', `${artworkId}.png`), + storageKey: join( + 'users', + prismaState.defaultUser.id, + 'artwork', + 'sha-missing-artwork.png', + ), mimeType: 'image/png', width: 1, height: 1, fileSizeBytes: BigInt(10), createdAt: new Date('2026-05-29T08:00:00.000Z'), - track: { - userId: prismaState.defaultUser.id, - }, + tracks: [{ userId: prismaState.defaultUser.id }], }); await expect( @@ -638,6 +679,8 @@ describe('Velody API wiring (e2e)', () => { const primaryTrackId = randomUUID(); const primaryAssetId = randomUUID(); const primaryArtworkId = randomUUID(); + const placeholderTrackId = randomUUID(); + const placeholderAssetId = randomUUID(); const secondaryTrackId = randomUUID(); const secondaryAssetId = randomUUID(); @@ -669,13 +712,29 @@ describe('Velody API wiring (e2e)', () => { }); prismaState.artworkAssets.set(primaryArtworkId, { id: primaryArtworkId, + userId: prismaState.defaultUser.id, sha256: 'artwork-sha-default', - storageKey: `library/artwork/${primaryArtworkId}.png`, + storageKey: `users/${prismaState.defaultUser.id}/artwork/artwork-sha-default.png`, mimeType: 'image/png', width: 512, height: 512, fileSizeBytes: BigInt(128), createdAt: new Date('2026-05-29T08:00:30.000Z'), + tracks: [{ userId: prismaState.defaultUser.id }], + }); + prismaState.audioAssets.set(placeholderAssetId, { + id: placeholderAssetId, + userId: prismaState.defaultUser.id, + trackId: placeholderTrackId, + sha256: 'sha-no-artwork', + storageKey: 'users/default/audio/sha-no-artwork.mp3', + originalFilename: 'no-artwork.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: BigInt(64), + durationMs: 201000, + sourceDeviceId: primaryDevice.deviceId, + createdAt: new Date('2026-05-29T08:00:45.000Z'), }); prismaState.audioAssets.set(secondaryAssetId, { id: secondaryAssetId, @@ -730,6 +789,25 @@ describe('Velody API wiring (e2e)', () => { createdAt: new Date('2026-05-29T08:01:00.000Z'), updatedAt: new Date('2026-05-29T08:03:00.000Z'), }); + prismaState.tracks.set(placeholderTrackId, { + id: placeholderTrackId, + userId: prismaState.defaultUser.id, + primaryAudioAssetId: placeholderAssetId, + artworkAssetId: null, + title: 'No Artwork Track', + artist: 'Velody', + album: null, + albumArtist: null, + genre: null, + discNumber: null, + trackNumber: null, + year: null, + durationMs: 201000, + status: 'ACTIVE', + deletedAt: null, + createdAt: new Date('2026-05-29T08:00:45.000Z'), + updatedAt: new Date('2026-05-29T08:02:30.000Z'), + }); const response = await libraryController.getTracks({ deviceId: primaryDevice.deviceId, @@ -754,6 +832,17 @@ describe('Velody API wiring (e2e)', () => { height: 512, }, }, + { + trackId: placeholderTrackId, + title: 'No Artwork Track', + artist: 'Velody', + durationSeconds: 201, + sha256: 'sha-no-artwork', + assetId: placeholderAssetId, + createdAt: '2026-05-29T08:00:45.000Z', + updatedAt: '2026-05-29T08:02:30.000Z', + artwork: null, + }, ], }); }); @@ -832,7 +921,194 @@ 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); }); + + it('supports upload finalize with embedded artwork and exposes remote artwork metadata', async () => { + const registerResponse = await devicesController.register({ + platform: 'MACOS', + deviceName: 'Artwork Upload Mac', + appVersion: '0.1.0', + }); + const bytes = sampleMp3Bytes('e2e-upload-artwork'); + const artworkBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==', + 'base64', + ); + const sha256 = sha256Hex(bytes); + const artworkSha256 = sha256Hex(artworkBytes); + + const prepareResponse = await uploadsController.prepare({ + deviceId: registerResponse.deviceId, + sha256, + originalFilename: 'e2e-upload-artwork.mp3', + sizeBytes: bytes.length, + }); + + expect(prepareResponse.status).toBe('upload_required'); + + const uploadResponse = await uploadsService.uploadFile( + prepareResponse.uploadId!, + createUploadRequest(bytes), + ); + + expect(uploadResponse.status).toBe('COMPLETED'); + + const finalizeResponse = await uploadsController.finalize( + prepareResponse.uploadId!, + { + title: 'Uploaded Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 222000, + artwork: { + dataBase64: artworkBytes.toString('base64'), + sha256: artworkSha256, + mimeType: 'image/png', + width: 1, + height: 1, + }, + }, + ); + + expect(finalizeResponse.trackId).toBeDefined(); + expect(prismaState.artworkAssets.size).toBe(1); + + const remoteLibrary = await libraryController.getTracks({ + deviceId: registerResponse.deviceId, + }); + expect(remoteLibrary.tracks).toEqual([ + expect.objectContaining({ + trackId: finalizeResponse.trackId, + artwork: { + artworkId: expect.any(String), + sha256: artworkSha256, + mimeType: 'image/png', + width: 1, + height: 1, + }, + }), + ]); + + const artworkAsset = [...prismaState.artworkAssets.values()][0]; + const storedArtworkBytes = await readFile( + join( + storageRoot, + 'users', + prismaState.defaultUser.id, + 'artwork', + `${artworkSha256}.png`, + ), + ); + expect(storedArtworkBytes.equals(artworkBytes)).toBe(true); + + const headers = new Map(); + const responseMock = { + setHeader(name: string, value: string) { + headers.set(name.toLowerCase(), String(value)); + }, + } as any; + + const streamable = await artworkController.download( + artworkAsset.id, + { deviceId: registerResponse.deviceId }, + responseMock, + ); + const downloadedArtworkBytes = await streamToBuffer(streamable.getStream()); + + expect(downloadedArtworkBytes.equals(artworkBytes)).toBe(true); + expect(headers.get('content-type')).toBe('image/png'); + }); + + it('attaches artwork through the deduped prepare exists path and returns non-null remote artwork metadata', async () => { + const registerResponse = await devicesController.register({ + platform: 'MACOS', + deviceName: 'Deduped Artwork Upload Mac', + appVersion: '0.1.0', + }); + const bytes = sampleMp3Bytes('e2e-deduped-artwork'); + const artworkBytes = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==', + 'base64', + ); + const sha256 = sha256Hex(bytes); + const artworkSha256 = sha256Hex(artworkBytes); + + const firstPrepare = await uploadsController.prepare({ + deviceId: registerResponse.deviceId, + sha256, + originalFilename: 'e2e-deduped-artwork.mp3', + sizeBytes: bytes.length, + }); + + expect(firstPrepare.status).toBe('upload_required'); + + const uploadResponse = await uploadsService.uploadFile( + firstPrepare.uploadId!, + createUploadRequest(bytes), + ); + + expect(uploadResponse.status).toBe('COMPLETED'); + + const firstFinalize = await uploadsController.finalize( + firstPrepare.uploadId!, + { + title: 'Deduped Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 222000, + }, + ); + + const secondPrepare = await uploadsController.prepare({ + deviceId: registerResponse.deviceId, + sha256, + originalFilename: 'e2e-deduped-artwork.mp3', + sizeBytes: bytes.length, + }); + + expect(secondPrepare.status).toBe('exists'); + expect(secondPrepare.uploadId).toBeDefined(); + expect(prismaState.audioAssets.size).toBe(1); + + const secondFinalize = await uploadsController.finalize( + secondPrepare.uploadId!, + { + title: 'Deduped Artwork Track', + artist: 'Velody', + album: 'Milestone 8.1', + durationMs: 222000, + artwork: { + dataBase64: artworkBytes.toString('base64'), + sha256: artworkSha256, + mimeType: 'image/png', + width: 1, + height: 1, + }, + }, + ); + + expect(secondFinalize.trackId).toBe(firstFinalize.trackId); + expect(secondFinalize.assetId).toBe(firstFinalize.assetId); + expect(prismaState.artworkAssets.size).toBe(1); + + const remoteLibrary = await libraryController.getTracks({ + deviceId: registerResponse.deviceId, + }); + expect(remoteLibrary.tracks).toEqual([ + expect.objectContaining({ + trackId: firstFinalize.trackId, + artwork: { + artworkId: expect.any(String), + sha256: artworkSha256, + mimeType: 'image/png', + width: 1, + height: 1, + }, + }), + ]); + }); + }); diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift index 3c0d0b7..5d56aaa 100644 --- a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift @@ -13,6 +13,28 @@ public enum LocalUploadStatus: String, Codable, Hashable, Sendable, CaseIterable case failed } +public struct LocalTrackArtwork: Codable, Hashable, Sendable { + public var localFilePath: String + public var sha256: String + public var mimeType: String + public var width: Int? + public var height: Int? + + public init( + localFilePath: String, + sha256: String, + mimeType: String, + width: Int? = nil, + height: Int? = nil + ) { + self.localFilePath = localFilePath + self.sha256 = sha256 + self.mimeType = mimeType + self.width = width + self.height = height + } +} + public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable { public let id: String public var title: String @@ -21,6 +43,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable { public var durationSeconds: Double? public var localFilePath: String public var sha256: String? + public var artwork: LocalTrackArtwork? public var uploadStatus: LocalUploadStatus? public var remoteTrackId: String? public var lastUploadError: String? @@ -33,6 +56,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable { durationSeconds: Double? = nil, localFilePath: String = "", sha256: String? = nil, + artwork: LocalTrackArtwork? = nil, uploadStatus: LocalUploadStatus? = nil, remoteTrackId: String? = nil, lastUploadError: String? = nil @@ -44,6 +68,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable { self.durationSeconds = durationSeconds self.localFilePath = localFilePath self.sha256 = sha256 + self.artwork = artwork self.uploadStatus = uploadStatus self.remoteTrackId = remoteTrackId self.lastUploadError = lastUploadError @@ -187,21 +212,46 @@ public struct UploadSessionStatusResponse: Codable, Hashable, Sendable { } public struct UploadFinalizeRequest: Codable, Hashable, Sendable { + public struct ArtworkPayload: Codable, Hashable, Sendable { + public var dataBase64: String + public var sha256: String + public var mimeType: String + public var width: Int? + public var height: Int? + + public init( + dataBase64: String, + sha256: String, + mimeType: String, + width: Int? = nil, + height: Int? = nil + ) { + self.dataBase64 = dataBase64 + self.sha256 = sha256 + self.mimeType = mimeType + self.width = width + self.height = height + } + } + public var title: String public var artist: String public var album: String? public var durationMs: Int? + public var artwork: ArtworkPayload? public init( title: String, artist: String, album: String? = nil, - durationMs: Int? = nil + durationMs: Int? = nil, + artwork: ArtworkPayload? = nil ) { self.title = title self.artist = artist self.album = album self.durationMs = durationMs + self.artwork = artwork } } diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalArtworkStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalArtworkStore.swift new file mode 100644 index 0000000..a2b219d --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalArtworkStore.swift @@ -0,0 +1,158 @@ +import Foundation + +public enum LocalArtworkStoreError: LocalizedError, Equatable, Sendable { + case emptyArtworkData + case missingLocalFile(path: String) + + public var errorDescription: String? { + switch self { + case .emptyArtworkData: + return "The local artwork data was empty." + case let .missingLocalFile(path): + return "The local artwork file is missing: \(path)" + } + } +} + +public protocol LocalArtworkStore: Actor { + func saveArtwork( + _ data: Data, + sha256: String, + mimeType: String + ) async throws -> String + func readArtwork(at localFilePath: String) async throws -> Data + func fileExists(at localFilePath: String) async -> Bool +} + +public actor FileLocalArtworkStore: LocalArtworkStore { + private let baseDirectoryURL: URL + private let fileManager: FileManager + + public init( + baseDirectoryURL: URL? = nil, + fileManager: FileManager = .default + ) throws { + self.fileManager = fileManager + if let baseDirectoryURL { + self.baseDirectoryURL = baseDirectoryURL + } else { + self.baseDirectoryURL = try Self.defaultBaseDirectoryURL(fileManager: fileManager) + } + } + + public func saveArtwork( + _ data: Data, + sha256: String, + mimeType: String + ) async throws -> String { + guard !data.isEmpty else { + throw LocalArtworkStoreError.emptyArtworkData + } + + try fileManager.createDirectory( + at: baseDirectoryURL, + withIntermediateDirectories: true + ) + + let fileURL = localFileURL(sha256: sha256, mimeType: mimeType) + if fileManager.fileExists(atPath: fileURL.path) { + return fileURL.standardizedFileURL.path + } + + try data.write(to: fileURL, options: .atomic) + return fileURL.standardizedFileURL.path + } + + public func readArtwork(at localFilePath: String) async throws -> Data { + let fileURL = URL(fileURLWithPath: localFilePath).standardizedFileURL + guard fileManager.fileExists(atPath: fileURL.path) else { + throw LocalArtworkStoreError.missingLocalFile(path: localFilePath) + } + + return try Data(contentsOf: fileURL) + } + + public func fileExists(at localFilePath: String) async -> Bool { + let fileURL = URL(fileURLWithPath: localFilePath).standardizedFileURL + return fileManager.fileExists(atPath: fileURL.path) + } + + private static func defaultBaseDirectoryURL(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("local-artwork", isDirectory: true) + } + + private func localFileURL(sha256: String, mimeType: String) -> URL { + baseDirectoryURL.appendingPathComponent( + "\(sha256).\(Self.fileExtension(for: mimeType))" + ) + } + + private static func fileExtension(for mimeType: String) -> String { + switch mimeType.lowercased() { + case "image/jpeg", "image/jpg": + return "jpg" + case "image/png": + return "png" + default: + return "img" + } + } +} + +public actor InMemoryLocalArtworkStore: LocalArtworkStore { + private var files: [String: Data] + + public init(files: [String: Data] = [:]) { + self.files = files + } + + public func saveArtwork( + _ data: Data, + sha256: String, + mimeType: String + ) async throws -> String { + guard !data.isEmpty else { + throw LocalArtworkStoreError.emptyArtworkData + } + + let localFilePath = Self.localFilePath(sha256: sha256, mimeType: mimeType) + files[localFilePath] = data + return localFilePath + } + + public func readArtwork(at localFilePath: String) async throws -> Data { + guard let data = files[localFilePath] else { + throw LocalArtworkStoreError.missingLocalFile(path: localFilePath) + } + + return data + } + + public func fileExists(at localFilePath: String) async -> Bool { + files[localFilePath] != nil + } + + private static func localFilePath(sha256: String, mimeType: String) -> String { + let fileExtension: String + + switch mimeType.lowercased() { + case "image/jpeg", "image/jpg": + fileExtension = "jpg" + case "image/png": + fileExtension = "png" + default: + fileExtension = "img" + } + + return "/in-memory/local-artwork/\(sha256).\(fileExtension)" + } +} diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift index cda07a7..0b6c71e 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalCatalogService.swift @@ -188,6 +188,7 @@ public actor DefaultLocalCatalogService: LocalCatalogService { || existingTrack.durationSeconds != scannedTrack.durationSeconds || existingTrack.localFilePath != scannedTrack.localFilePath || existingTrack.sha256 != scannedTrack.sha256 + || existingTrack.artwork != scannedTrack.artwork || existingTrack.fileModifiedAt != scannedTrack.fileModifiedAt || existingTrack.isDeleted @@ -198,6 +199,7 @@ public actor DefaultLocalCatalogService: LocalCatalogService { updatedTrack.durationSeconds = scannedTrack.durationSeconds updatedTrack.localFilePath = scannedTrack.localFilePath updatedTrack.sha256 = scannedTrack.sha256 + updatedTrack.artwork = scannedTrack.artwork updatedTrack.fileModifiedAt = scannedTrack.fileModifiedAt updatedTrack.lastScannedAt = scannedAt updatedTrack.isDeleted = false diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalMusicDiscovery.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalMusicDiscovery.swift index 8fd3aa5..9aab41a 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalMusicDiscovery.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalMusicDiscovery.swift @@ -2,21 +2,46 @@ import Foundation import VelodyDomain public struct LocalTrackMetadata: Hashable, Sendable { + public struct EmbeddedArtworkPayload: Hashable, Sendable { + public var data: Data + public var sha256: String + public var mimeType: String + public var width: Int? + public var height: Int? + + public init( + data: Data, + sha256: String, + mimeType: String, + width: Int? = nil, + height: Int? = nil + ) { + self.data = data + self.sha256 = sha256 + self.mimeType = mimeType + self.width = width + self.height = height + } + } + public var title: String? public var artist: String? public var album: String? public var durationSeconds: Double? + public var artwork: EmbeddedArtworkPayload? public init( title: String? = nil, artist: String? = nil, album: String? = nil, - durationSeconds: Double? = nil + durationSeconds: Double? = nil, + artwork: EmbeddedArtworkPayload? = nil ) { self.title = title self.artist = artist self.album = album self.durationSeconds = durationSeconds + self.artwork = artwork } } diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackArtworkUploadPayloadBuilder.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackArtworkUploadPayloadBuilder.swift new file mode 100644 index 0000000..5732fed --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackArtworkUploadPayloadBuilder.swift @@ -0,0 +1,27 @@ +import Foundation +import VelodyDomain + +public actor LocalTrackArtworkUploadPayloadBuilder { + private let artworkStore: any LocalArtworkStore + + public init(artworkStore: any LocalArtworkStore) { + self.artworkStore = artworkStore + } + + public func makePayload( + for artwork: LocalTrackArtwork? + ) async throws -> UploadFinalizeRequest.ArtworkPayload? { + guard let artwork else { + return nil + } + + let data = try await artworkStore.readArtwork(at: artwork.localFilePath) + return UploadFinalizeRequest.ArtworkPayload( + dataBase64: data.base64EncodedString(), + sha256: artwork.sha256, + mimeType: artwork.mimeType, + width: artwork.width, + height: artwork.height + ) + } +} diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackDTOs.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackDTOs.swift index 14918ae..39f30a0 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackDTOs.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/LocalTrackDTOs.swift @@ -15,6 +15,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable { public var durationSeconds: Double? public var localFilePath: String public var sha256: String? + public var artwork: LocalTrackArtwork? public var uploadStatus: LocalUploadStatus public var remoteTrackId: String? public var lastUploadError: String? @@ -34,6 +35,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable { durationSeconds: Double? = nil, localFilePath: String = "", sha256: String? = nil, + artwork: LocalTrackArtwork? = nil, uploadStatus: LocalUploadStatus = .localOnly, remoteTrackId: String? = nil, lastUploadError: String? = nil, @@ -52,6 +54,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable { self.durationSeconds = durationSeconds self.localFilePath = localFilePath self.sha256 = sha256 + self.artwork = artwork self.uploadStatus = uploadStatus self.remoteTrackId = remoteTrackId self.lastUploadError = lastUploadError @@ -77,6 +80,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable { durationSeconds: scannedTrack.durationSeconds, localFilePath: scannedTrack.localFilePath, sha256: scannedTrack.sha256, + artwork: scannedTrack.artwork, uploadStatus: .localOnly, remoteTrackId: nil, lastUploadError: nil, @@ -103,6 +107,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable { durationSeconds: libraryTrack.durationSeconds, localFilePath: libraryTrack.localFilePath, sha256: libraryTrack.sha256, + artwork: libraryTrack.artwork, uploadStatus: libraryTrack.uploadStatus ?? .localOnly, remoteTrackId: libraryTrack.remoteTrackId, lastUploadError: libraryTrack.lastUploadError, @@ -132,6 +137,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable { durationSeconds: durationSeconds, localFilePath: localFilePath, sha256: sha256, + artwork: artwork, uploadStatus: uploadStatus, remoteTrackId: remoteTrackId, lastUploadError: lastUploadError @@ -168,6 +174,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable { public var durationSeconds: Double? public var localFilePath: String public var sha256: String + public var artwork: LocalTrackArtwork? public var fileModifiedAt: Date public init( @@ -177,6 +184,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable { durationSeconds: Double? = nil, localFilePath: String, sha256: String, + artwork: LocalTrackArtwork? = nil, fileModifiedAt: Date ) { self.title = title @@ -185,6 +193,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable { self.durationSeconds = durationSeconds self.localFilePath = localFilePath self.sha256 = sha256 + self.artwork = artwork self.fileModifiedAt = fileModifiedAt } diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/MP3EmbeddedArtworkExtractor.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/MP3EmbeddedArtworkExtractor.swift new file mode 100644 index 0000000..6543fe0 --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/MP3EmbeddedArtworkExtractor.swift @@ -0,0 +1,192 @@ +import CryptoKit +import Foundation +import ImageIO + +public struct MP3EmbeddedArtworkExtractor: Sendable { + public init() {} + + public func extractArtwork( + from fileURL: URL + ) throws -> LocalTrackMetadata.EmbeddedArtworkPayload? { + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { + try? fileHandle.close() + } + + let header = try fileHandle.read(upToCount: 10) ?? Data() + guard header.count == 10 else { + return nil + } + + guard header.prefix(3) == Data("ID3".utf8) else { + return nil + } + + let version = header[3] + let tagSize = synchsafeInteger(from: header[6...9]) + guard tagSize > 0 else { + return nil + } + + let tagData = try fileHandle.read(upToCount: tagSize) ?? Data() + guard tagData.count == tagSize else { + return nil + } + + return parseAPICFrame(from: tagData, version: version) + } + + private func parseAPICFrame( + from tagData: Data, + version: UInt8 + ) -> LocalTrackMetadata.EmbeddedArtworkPayload? { + var offset = 0 + + while offset + 10 <= tagData.count { + let frameHeader = tagData[offset..<(offset + 10)] + let frameIDData = Data(frameHeader.prefix(4)) + + if frameIDData.allSatisfy({ $0 == 0 }) { + break + } + + guard let frameID = String(data: frameIDData, encoding: .isoLatin1), + frameID.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) }) + else { + break + } + + let frameSizeSlice = frameHeader.dropFirst(4).prefix(4) + let frameSize = version >= 4 + ? synchsafeInteger(from: frameSizeSlice) + : bigEndianInteger(from: frameSizeSlice) + + guard frameSize > 0, offset + 10 + frameSize <= tagData.count else { + break + } + + let frameData = tagData[(offset + 10)..<(offset + 10 + frameSize)] + if frameID == "APIC" { + return parseAPICBody(Data(frameData)) + } + + offset += 10 + frameSize + } + + return nil + } + + private func parseAPICBody( + _ frameData: Data + ) -> LocalTrackMetadata.EmbeddedArtworkPayload? { + guard frameData.count > 4 else { + return nil + } + + let textEncoding = frameData[0] + guard let mimeTypeEnd = frameData[1...].firstIndex(of: 0x00), + mimeTypeEnd < frameData.endIndex + else { + return nil + } + + let rawMimeType = String( + data: frameData[1.. Data.Index { + switch textEncoding { + case 0x01, 0x02: + var index = descriptionStart + while index + 1 < frameData.endIndex { + if frameData[index] == 0x00, frameData[index + 1] == 0x00 { + return index + 2 + } + + index += 1 + } + default: + if let terminatorIndex = frameData[descriptionStart...].firstIndex(of: 0x00) { + return terminatorIndex + 1 + } + } + + return descriptionStart + } + + private func normalizedMimeType(_ mimeType: String?) -> String? { + switch mimeType?.lowercased() { + case "image/jpeg", "image/jpg": + return "image/jpeg" + case "image/png": + return "image/png" + default: + return nil + } + } + + private func imageDimensions(for data: Data) -> (width: Int?, height: Int?) { + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any] + else { + return (nil, nil) + } + + return ( + properties[kCGImagePropertyPixelWidth] as? Int, + properties[kCGImagePropertyPixelHeight] as? Int + ) + } + + private func synchsafeInteger(from bytes: S) -> Int where S.Element == UInt8 { + bytes.reduce(0) { partial, byte in + (partial << 7) | Int(byte & 0x7f) + } + } + + private func bigEndianInteger(from bytes: S) -> Int where S.Element == UInt8 { + bytes.reduce(0) { partial, byte in + (partial << 8) | Int(byte) + } + } +} diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackEntity.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackEntity.swift index 6bc82ea..24b90fa 100644 --- a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackEntity.swift +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/TrackEntity.swift @@ -13,6 +13,11 @@ final class TrackEntity { var durationSeconds: Double? var localFilePath: String var sha256: String? + var localArtworkFilePath: String? + var artworkSHA256: String? + var artworkMimeType: String? + var artworkWidth: Int? + var artworkHeight: Int? var uploadStatusRawValue: String? var remoteTrackID: String? var lastUploadError: String? @@ -33,6 +38,11 @@ final class TrackEntity { durationSeconds = track.durationSeconds localFilePath = track.localFilePath sha256 = track.sha256 + localArtworkFilePath = track.artwork?.localFilePath + artworkSHA256 = track.artwork?.sha256 + artworkMimeType = track.artwork?.mimeType + artworkWidth = track.artwork?.width + artworkHeight = track.artwork?.height uploadStatusRawValue = track.uploadStatus.rawValue remoteTrackID = track.remoteTrackId lastUploadError = track.lastUploadError @@ -54,6 +64,7 @@ final class TrackEntity { durationSeconds: durationSeconds, localFilePath: localFilePath, sha256: sha256, + artwork: localArtwork, uploadStatus: LocalUploadStatus(rawValue: uploadStatusRawValue ?? "") ?? .localOnly, remoteTrackId: remoteTrackID, lastUploadError: lastUploadError, @@ -76,6 +87,11 @@ final class TrackEntity { durationSeconds = track.durationSeconds localFilePath = track.localFilePath sha256 = track.sha256 + localArtworkFilePath = track.artwork?.localFilePath + artworkSHA256 = track.artwork?.sha256 + artworkMimeType = track.artwork?.mimeType + artworkWidth = track.artwork?.width + artworkHeight = track.artwork?.height uploadStatusRawValue = track.uploadStatus.rawValue remoteTrackID = track.remoteTrackId lastUploadError = track.lastUploadError @@ -86,4 +102,21 @@ final class TrackEntity { createdAt = track.createdAt updatedAt = track.updatedAt } + + private var localArtwork: LocalTrackArtwork? { + guard let localArtworkFilePath, + let artworkSHA256, + let artworkMimeType + else { + return nil + } + + return LocalTrackArtwork( + localFilePath: localArtworkFilePath, + sha256: artworkSHA256, + mimeType: artworkMimeType, + width: artworkWidth, + height: artworkHeight + ) + } } diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift index 41a8f0f..33c80a2 100644 --- a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift @@ -91,6 +91,42 @@ final class LocalCatalogServiceTests: XCTestCase { XCTAssertEqual(storedTracks.first?.sha256, "sha-updated") } + func testScanPersistsEmbeddedArtworkReferenceWithoutStoringArtworkBytesInSwiftData() async throws { + let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true) + let service = DefaultLocalCatalogService(repository: repository) + let modifiedAt = Date(timeIntervalSince1970: 2_500) + let artwork = LocalTrackArtwork( + localFilePath: "/Application Support/Velody/local-artwork/artwork-sha.png", + sha256: "artwork-sha", + mimeType: "image/png", + width: 512, + height: 512 + ) + + let result = try await service.reconcileScanResults( + [ + ScannedLocalTrack( + title: "Art Track", + artist: "Artist", + album: "Album", + durationSeconds: 180, + localFilePath: "/Music/ArtTrack.mp3", + sha256: "sha-art-track", + artwork: artwork, + fileModifiedAt: modifiedAt + ), + ], + in: URL(fileURLWithPath: "/Music"), + scannedAt: Date(timeIntervalSince1970: 2_600) + ) + + let storedTrack = try XCTUnwrap(result.tracks.first) + XCTAssertEqual(storedTrack.artwork, artwork) + + let reloadedTrack = try await repository.findTrack(trackID: storedTrack.id) + XCTAssertEqual(reloadedTrack?.artwork, artwork) + } + func testRescanMarksDeletedTracksAndReactivatesExistingSHA() async throws { let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true) let service = DefaultLocalCatalogService(repository: repository) diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalTrackArtworkUploadPayloadBuilderTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalTrackArtworkUploadPayloadBuilderTests.swift new file mode 100644 index 0000000..94f4850 --- /dev/null +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalTrackArtworkUploadPayloadBuilderTests.swift @@ -0,0 +1,50 @@ +import Foundation +import XCTest +import VelodyDomain +@testable import VelodyPersistence + +final class LocalTrackArtworkUploadPayloadBuilderTests: XCTestCase { + func testBuilderIncludesArtworkPayloadWhenArtworkExists() async throws { + let store = InMemoryLocalArtworkStore() + let data = sampleArtworkData() + let localFilePath = try await store.saveArtwork( + data, + sha256: "artwork-sha", + mimeType: "image/png" + ) + let builder = LocalTrackArtworkUploadPayloadBuilder(artworkStore: store) + + let payload = try await builder.makePayload( + for: LocalTrackArtwork( + localFilePath: localFilePath, + sha256: "artwork-sha", + mimeType: "image/png", + width: 1, + height: 1 + ) + ) + + XCTAssertEqual(payload?.dataBase64, data.base64EncodedString()) + XCTAssertEqual(payload?.sha256, "artwork-sha") + XCTAssertEqual(payload?.mimeType, "image/png") + XCTAssertEqual(payload?.width, 1) + XCTAssertEqual(payload?.height, 1) + } + + func testBuilderReturnsNilWhenArtworkIsMissing() async throws { + let builder = LocalTrackArtworkUploadPayloadBuilder( + artworkStore: InMemoryLocalArtworkStore() + ) + + let payload = try await builder.makePayload(for: nil) + + XCTAssertNil(payload) + } +} + +private func sampleArtworkData() -> Data { + Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==" + )! +} diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/MP3EmbeddedArtworkExtractorTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/MP3EmbeddedArtworkExtractorTests.swift new file mode 100644 index 0000000..31d268c --- /dev/null +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/MP3EmbeddedArtworkExtractorTests.swift @@ -0,0 +1,113 @@ +import CryptoKit +import Foundation +import XCTest +@testable import VelodyPersistence + +final class MP3EmbeddedArtworkExtractorTests: XCTestCase { + func testExtractorReadsEmbeddedPNGArtworkFromID3APICFrame() throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let fileURL = tempDirectory.appendingPathComponent("artwork-test.mp3") + let extractor = MP3EmbeddedArtworkExtractor() + let artworkData = sampleArtworkData() + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + try makeMP3FileWithEmbeddedArtwork( + artworkData: artworkData, + mimeType: "image/png", + at: fileURL + ) + + let extractedArtwork = try XCTUnwrap( + extractor.extractArtwork(from: fileURL) + ) + + XCTAssertEqual(extractedArtwork.data, artworkData) + XCTAssertEqual(extractedArtwork.mimeType, "image/png") + XCTAssertEqual(extractedArtwork.width, 1) + XCTAssertEqual(extractedArtwork.height, 1) + XCTAssertEqual(extractedArtwork.sha256, sha256Hex(artworkData)) + } + + func testExtractorReturnsNilWhenMP3HasNoEmbeddedArtwork() throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let fileURL = tempDirectory.appendingPathComponent("no-artwork.mp3") + let extractor = MP3EmbeddedArtworkExtractor() + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + try Data([ + 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]).write(to: fileURL) + + XCTAssertNil(try extractor.extractArtwork(from: fileURL)) + } +} + +private func makeMP3FileWithEmbeddedArtwork( + artworkData: Data, + mimeType: String, + at fileURL: URL +) throws { + let mimeTypeData = Data(mimeType.utf8) + Data([0x00]) + let frameBody = Data([0x00]) // ISO-8859-1 encoding + + mimeTypeData + + Data([0x03]) // front cover + + Data([0x00]) // empty description + + artworkData + + let frameSize = UInt32(frameBody.count) + let frameHeader = Data("APIC".utf8) + + Data([ + UInt8((frameSize >> 24) & 0xff), + UInt8((frameSize >> 16) & 0xff), + UInt8((frameSize >> 8) & 0xff), + UInt8(frameSize & 0xff), + 0x00, + 0x00, + ]) + let tagBody = frameHeader + frameBody + let tagSize = synchsafeBytes(for: tagBody.count) + let id3Header = Data([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]) + tagSize + let mp3Bytes = id3Header + tagBody + Data([ + 0xff, 0xfb, 0x90, 0x64, 0x00, 0x00, 0x00, 0x00, + ]) + + try mp3Bytes.write(to: fileURL) +} + +private func synchsafeBytes(for value: Int) -> Data { + Data([ + UInt8((value >> 21) & 0x7f), + UInt8((value >> 14) & 0x7f), + UInt8((value >> 7) & 0x7f), + UInt8(value & 0x7f), + ]) +} + +private func sampleArtworkData() -> Data { + Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==" + )! +} + +private func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() +}