Fix artwork upload pipeline

This commit is contained in:
diyaa 2026-05-31 01:20:56 +02:00
parent 7b1952794c
commit 18ed79e3c4
24 changed files with 1652 additions and 56 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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");

View File

@ -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")
}

View File

@ -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),
);
});
});

View File

@ -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<NestExpressApplication> {
const app = await NestFactory.create<NestExpressApplication>(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({

View File

@ -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`),
mimeType: 'image/jpeg',
track: {
userId: ownerId,
},
storageKey: join('users', ownerId, 'artwork', `${artworkId}.jpg`),
mimeType: 'image/jpeg',
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`),
mimeType: 'image/png',
track: {
userId,
},
storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
mimeType: 'image/png',
tracks: [{ userId }],
});
await expect(

View File

@ -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.');
}

View File

@ -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`);
}

View File

@ -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 {

View File

@ -16,6 +16,7 @@ function createPrismaMock() {
const devices = new Map<string, any>();
const tracks = new Map<string, any>();
const audioAssets = new Map<string, any>();
const artworkAssets = new Map<string, any>();
const uploadSessions = new Map<string, any>();
const libraryEvents = new Map<bigint, any>();
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);
});
});

View File

@ -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<PreparedArtworkAssetInput> {
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<PrismaService, 'artworkAsset'>,
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<void> {
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;

View File

@ -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<string, string>();
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,
},
}),
]);
});
});

View File

@ -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
}
}

View File

@ -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)"
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
)
}
}

View File

@ -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
}

View File

@ -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..<mimeTypeEnd],
encoding: .isoLatin1
)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let mimeType = normalizedMimeType(rawMimeType) else {
return nil
}
let pictureTypeIndex = frameData.index(after: mimeTypeEnd)
guard pictureTypeIndex < frameData.endIndex else {
return nil
}
let descriptionStart = frameData.index(after: pictureTypeIndex)
let imageDataStart = startOfImageData(
in: frameData,
descriptionStart: descriptionStart,
textEncoding: textEncoding
)
guard imageDataStart < frameData.endIndex else {
return nil
}
let imageData = Data(frameData[imageDataStart...])
guard !imageData.isEmpty else {
return nil
}
let dimensions = imageDimensions(for: imageData)
return LocalTrackMetadata.EmbeddedArtworkPayload(
data: imageData,
sha256: SHA256.hash(data: imageData)
.map { String(format: "%02x", $0) }
.joined(),
mimeType: mimeType,
width: dimensions.width,
height: dimensions.height
)
}
private func startOfImageData(
in frameData: Data,
descriptionStart: Data.Index,
textEncoding: UInt8
) -> 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<S: Sequence>(from bytes: S) -> Int where S.Element == UInt8 {
bytes.reduce(0) { partial, byte in
(partial << 7) | Int(byte & 0x7f)
}
}
private func bigEndianInteger<S: Sequence>(from bytes: S) -> Int where S.Element == UInt8 {
bytes.reduce(0) { partial, byte in
(partial << 8) | Int(byte)
}
}
}

View File

@ -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
)
}
}

View File

@ -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)

View File

@ -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=="
)!
}

View File

@ -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()
}