Fix artwork upload pipeline
This commit is contained in:
parent
7b1952794c
commit
18ed79e3c4
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
73
backend/src/app.factory.spec.ts
Normal file
73
backend/src/app.factory.spec.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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=="
|
||||
)!
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user