Fix artwork upload pipeline
This commit is contained in:
parent
7b1952794c
commit
18ed79e3c4
@ -1,34 +1,46 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import VelodyDomain
|
||||||
import VelodyPersistence
|
import VelodyPersistence
|
||||||
|
|
||||||
final class AVFoundationMetadataReader: MetadataReader {
|
final class AVFoundationMetadataReader: MetadataReader {
|
||||||
|
private let artworkExtractor: MP3EmbeddedArtworkExtractor
|
||||||
|
|
||||||
|
init(artworkExtractor: MP3EmbeddedArtworkExtractor = MP3EmbeddedArtworkExtractor()) {
|
||||||
|
self.artworkExtractor = artworkExtractor
|
||||||
|
}
|
||||||
|
|
||||||
func readMetadata(for fileURL: URL) async throws -> LocalTrackMetadata {
|
func readMetadata(for fileURL: URL) async throws -> LocalTrackMetadata {
|
||||||
let asset = AVURLAsset(url: fileURL)
|
let asset = AVURLAsset(url: fileURL)
|
||||||
let commonMetadata = try await asset.load(.commonMetadata)
|
let commonMetadata = try await asset.load(.commonMetadata)
|
||||||
let duration = try? await asset.load(.duration)
|
let duration = try? await asset.load(.duration)
|
||||||
|
let artwork = try? artworkExtractor.extractArtwork(from: fileURL)
|
||||||
|
|
||||||
return LocalTrackMetadata(
|
return LocalTrackMetadata(
|
||||||
title: commonMetadata.firstStringValue(for: .commonIdentifierTitle),
|
title: commonMetadata.firstStringValue(for: .commonIdentifierTitle),
|
||||||
artist: commonMetadata.firstStringValue(for: .commonIdentifierArtist),
|
artist: commonMetadata.firstStringValue(for: .commonIdentifierArtist),
|
||||||
album: commonMetadata.firstStringValue(for: .commonIdentifierAlbumName),
|
album: commonMetadata.firstStringValue(for: .commonIdentifierAlbumName),
|
||||||
durationSeconds: duration?.seconds.validDurationSeconds
|
durationSeconds: duration?.seconds.validDurationSeconds,
|
||||||
|
artwork: artwork
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class FileSystemLocalMusicScanner: LocalMusicScanner {
|
final class FileSystemLocalMusicScanner: LocalMusicScanner {
|
||||||
private let metadataReader: any MetadataReader
|
private let metadataReader: any MetadataReader
|
||||||
|
private let artworkStore: any LocalArtworkStore
|
||||||
private let fileHasher: SHA256FileHasher
|
private let fileHasher: SHA256FileHasher
|
||||||
private let fileManager: FileManager
|
private let fileManager: FileManager
|
||||||
|
|
||||||
init(
|
init(
|
||||||
metadataReader: any MetadataReader,
|
metadataReader: any MetadataReader,
|
||||||
|
artworkStore: any LocalArtworkStore,
|
||||||
fileHasher: SHA256FileHasher = SHA256FileHasher(),
|
fileHasher: SHA256FileHasher = SHA256FileHasher(),
|
||||||
fileManager: FileManager = .default
|
fileManager: FileManager = .default
|
||||||
) {
|
) {
|
||||||
self.metadataReader = metadataReader
|
self.metadataReader = metadataReader
|
||||||
|
self.artworkStore = artworkStore
|
||||||
self.fileHasher = fileHasher
|
self.fileHasher = fileHasher
|
||||||
self.fileManager = fileManager
|
self.fileManager = fileManager
|
||||||
}
|
}
|
||||||
@ -65,6 +77,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let metadata = try await metadataReader.readMetadata(for: fileURL)
|
let metadata = try await metadataReader.readMetadata(for: fileURL)
|
||||||
|
let artwork = try await persistedArtwork(from: metadata.artwork)
|
||||||
discoveredTracks.append(
|
discoveredTracks.append(
|
||||||
ScannedLocalTrack(
|
ScannedLocalTrack(
|
||||||
title: metadata.title?.trimmedNonEmpty ?? fallbackTitle,
|
title: metadata.title?.trimmedNonEmpty ?? fallbackTitle,
|
||||||
@ -73,6 +86,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
|
|||||||
durationSeconds: metadata.durationSeconds,
|
durationSeconds: metadata.durationSeconds,
|
||||||
localFilePath: fileURL.path,
|
localFilePath: fileURL.path,
|
||||||
sha256: sha256,
|
sha256: sha256,
|
||||||
|
artwork: artwork,
|
||||||
fileModifiedAt: fileModifiedAt
|
fileModifiedAt: fileModifiedAt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -85,6 +99,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
|
|||||||
durationSeconds: nil,
|
durationSeconds: nil,
|
||||||
localFilePath: fileURL.path,
|
localFilePath: fileURL.path,
|
||||||
sha256: sha256,
|
sha256: sha256,
|
||||||
|
artwork: nil,
|
||||||
fileModifiedAt: fileModifiedAt
|
fileModifiedAt: fileModifiedAt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -125,6 +140,28 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
|
|||||||
|
|
||||||
return .distantPast
|
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 {
|
final class SHA256FileHasher {
|
||||||
|
|||||||
@ -37,6 +37,8 @@ final class MacLibraryViewModel {
|
|||||||
private let catalogService: any LocalCatalogService
|
private let catalogService: any LocalCatalogService
|
||||||
private let trackRepository: any TrackRepository
|
private let trackRepository: any TrackRepository
|
||||||
private let localMusicScanner: any LocalMusicScanner
|
private let localMusicScanner: any LocalMusicScanner
|
||||||
|
private let localArtworkStore: any LocalArtworkStore
|
||||||
|
private let artworkUploadPayloadBuilder: LocalTrackArtworkUploadPayloadBuilder
|
||||||
private let playbackController: PlaybackController
|
private let playbackController: PlaybackController
|
||||||
private let keychainService: any KeychainService
|
private let keychainService: any KeychainService
|
||||||
private let userDefaults: UserDefaults
|
private let userDefaults: UserDefaults
|
||||||
@ -49,8 +51,10 @@ final class MacLibraryViewModel {
|
|||||||
fileManager: FileManager = .default
|
fileManager: FileManager = .default
|
||||||
) {
|
) {
|
||||||
let folderAccessService = FolderAccessService()
|
let folderAccessService = FolderAccessService()
|
||||||
|
let localArtworkStore = Self.makeLocalArtworkStore()
|
||||||
let localMusicScanner = FileSystemLocalMusicScanner(
|
let localMusicScanner = FileSystemLocalMusicScanner(
|
||||||
metadataReader: AVFoundationMetadataReader()
|
metadataReader: AVFoundationMetadataReader(),
|
||||||
|
artworkStore: localArtworkStore
|
||||||
)
|
)
|
||||||
let repository = Self.makeTrackRepository()
|
let repository = Self.makeTrackRepository()
|
||||||
let playbackController = PlaybackController(
|
let playbackController = PlaybackController(
|
||||||
@ -64,6 +68,10 @@ final class MacLibraryViewModel {
|
|||||||
self.catalogService = DefaultLocalCatalogService(repository: repository)
|
self.catalogService = DefaultLocalCatalogService(repository: repository)
|
||||||
self.trackRepository = repository
|
self.trackRepository = repository
|
||||||
self.localMusicScanner = localMusicScanner
|
self.localMusicScanner = localMusicScanner
|
||||||
|
self.localArtworkStore = localArtworkStore
|
||||||
|
self.artworkUploadPayloadBuilder = LocalTrackArtworkUploadPayloadBuilder(
|
||||||
|
artworkStore: localArtworkStore
|
||||||
|
)
|
||||||
self.playbackController = playbackController
|
self.playbackController = playbackController
|
||||||
self.keychainService = keychainService
|
self.keychainService = keychainService
|
||||||
self.userDefaults = userDefaults
|
self.userDefaults = userDefaults
|
||||||
@ -450,6 +458,35 @@ final class MacLibraryViewModel {
|
|||||||
|
|
||||||
switch prepareResponse.status {
|
switch prepareResponse.status {
|
||||||
case .exists:
|
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
|
let remoteTrackID = prepareResponse.trackId ?? currentTrack(for: trackID)?.remoteTrackId
|
||||||
try await setTrackUploadState(
|
try await setTrackUploadState(
|
||||||
trackID: trackID,
|
trackID: trackID,
|
||||||
@ -487,26 +524,10 @@ final class MacLibraryViewModel {
|
|||||||
throw UploadPipelineError.uploadDidNotComplete(uploadResponse.status.rawValue)
|
throw UploadPipelineError.uploadDidNotComplete(uploadResponse.status.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
try await setTrackUploadState(
|
let finalizeResponse = try await finalizePreparedUpload(
|
||||||
trackID: trackID,
|
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,
|
uploadId: uploadId,
|
||||||
payload: UploadFinalizeRequest(
|
apiClient: apiClient
|
||||||
title: track.title,
|
|
||||||
artist: track.artist,
|
|
||||||
album: track.album,
|
|
||||||
durationMs: durationMilliseconds(from: track.durationSeconds)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try await setTrackUploadState(
|
try await setTrackUploadState(
|
||||||
@ -516,7 +537,8 @@ final class MacLibraryViewModel {
|
|||||||
lastUploadError: nil,
|
lastUploadError: nil,
|
||||||
progress: 1
|
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)
|
return .success(remoteTrackId: finalizeResponse.trackId)
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 {
|
private func restoreDeviceIdentity() async {
|
||||||
do {
|
do {
|
||||||
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
|
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
|
||||||
@ -734,6 +787,14 @@ final class MacLibraryViewModel {
|
|||||||
return InMemoryTrackRepository()
|
return InMemoryTrackRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeLocalArtworkStore() -> any LocalArtworkStore {
|
||||||
|
if let store = try? FileLocalArtworkStore() {
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
return InMemoryLocalArtworkStore()
|
||||||
|
}
|
||||||
|
|
||||||
private static func scanStatus(
|
private static func scanStatus(
|
||||||
for result: LocalCatalogScanResult,
|
for result: LocalCatalogScanResult,
|
||||||
activeTrackCount: Int
|
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[]
|
devices Device[]
|
||||||
tracks Track[]
|
tracks Track[]
|
||||||
audioAssets AudioAsset[]
|
audioAssets AudioAsset[]
|
||||||
|
artworkAssets ArtworkAsset[]
|
||||||
uploadSessions UploadSession[]
|
uploadSessions UploadSession[]
|
||||||
libraryEvents LibraryEvent[]
|
libraryEvents LibraryEvent[]
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ model Track {
|
|||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @db.Uuid @map("user_id")
|
userId String @db.Uuid @map("user_id")
|
||||||
primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_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
|
title String
|
||||||
artist String
|
artist String
|
||||||
album String?
|
album String?
|
||||||
@ -96,16 +97,20 @@ model AudioAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ArtworkAsset {
|
model ArtworkAsset {
|
||||||
|
userId String? @db.Uuid @map("user_id")
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
sha256 String @unique
|
sha256 String
|
||||||
storageKey String @unique @map("storage_key")
|
storageKey String @unique @map("storage_key")
|
||||||
mimeType String @map("mime_type")
|
mimeType String @map("mime_type")
|
||||||
width Int?
|
width Int?
|
||||||
height Int?
|
height Int?
|
||||||
fileSizeBytes BigInt @map("file_size_bytes")
|
fileSizeBytes BigInt @map("file_size_bytes")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
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")
|
@@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 { ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
export async function createApp() {
|
export const API_JSON_BODY_LIMIT = '2mb';
|
||||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
|
||||||
|
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.setGlobalPrefix('api');
|
||||||
app.enableVersioning({
|
app.enableVersioning({
|
||||||
|
|||||||
@ -63,7 +63,7 @@ describe('ArtworkService', () => {
|
|||||||
const userId = randomUUID();
|
const userId = randomUUID();
|
||||||
const deviceId = randomUUID();
|
const deviceId = randomUUID();
|
||||||
const artworkId = randomUUID();
|
const artworkId = randomUUID();
|
||||||
const storageKey = join('library', 'artwork', `${artworkId}.png`);
|
const storageKey = join('users', userId, 'artwork', `${artworkId}.png`);
|
||||||
const bytes = Buffer.from(
|
const bytes = Buffer.from(
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
||||||
'base64',
|
'base64',
|
||||||
@ -71,11 +71,10 @@ describe('ArtworkService', () => {
|
|||||||
|
|
||||||
state.devices.set(deviceId, { id: deviceId, userId });
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
state.artworkAssets.set(artworkId, {
|
state.artworkAssets.set(artworkId, {
|
||||||
|
userId,
|
||||||
storageKey,
|
storageKey,
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
track: {
|
tracks: [{ userId }],
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filePath = storageService.resolve(storageKey);
|
const filePath = storageService.resolve(storageKey);
|
||||||
@ -97,11 +96,10 @@ describe('ArtworkService', () => {
|
|||||||
|
|
||||||
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId });
|
||||||
state.artworkAssets.set(artworkId, {
|
state.artworkAssets.set(artworkId, {
|
||||||
storageKey: join('library', 'artwork', `${artworkId}.jpg`),
|
userId: ownerId,
|
||||||
|
storageKey: join('users', ownerId, 'artwork', `${artworkId}.jpg`),
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
track: {
|
tracks: [{ userId: ownerId }],
|
||||||
userId: ownerId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -116,11 +114,10 @@ describe('ArtworkService', () => {
|
|||||||
|
|
||||||
state.devices.set(deviceId, { id: deviceId, userId });
|
state.devices.set(deviceId, { id: deviceId, userId });
|
||||||
state.artworkAssets.set(artworkId, {
|
state.artworkAssets.set(artworkId, {
|
||||||
storageKey: join('library', 'artwork', `${artworkId}.png`),
|
userId,
|
||||||
|
storageKey: join('users', userId, 'artwork', `${artworkId}.png`),
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
track: {
|
tracks: [{ userId }],
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@ -38,9 +38,11 @@ export class ArtworkService {
|
|||||||
const artwork = await this.prismaService.artworkAsset.findUnique({
|
const artwork = await this.prismaService.artworkAsset.findUnique({
|
||||||
where: { id: artworkId },
|
where: { id: artworkId },
|
||||||
select: {
|
select: {
|
||||||
|
userId: true,
|
||||||
storageKey: true,
|
storageKey: true,
|
||||||
mimeType: true,
|
mimeType: true,
|
||||||
track: {
|
tracks: {
|
||||||
|
take: 1,
|
||||||
select: {
|
select: {
|
||||||
userId: true,
|
userId: true,
|
||||||
},
|
},
|
||||||
@ -48,11 +50,16 @@ export class ArtworkService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!artwork || !artwork.track) {
|
if (!artwork) {
|
||||||
throw new NotFoundException('Artwork not found');
|
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.');
|
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));
|
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 {
|
tempUploadStorageKey(uploadId: string): string {
|
||||||
return join('temp', 'uploads', `${uploadId}.part`);
|
return join('temp', 'uploads', `${uploadId}.part`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { UploadSessionStatus } from '@prisma/client';
|
import { UploadSessionStatus } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@ -9,6 +10,7 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UploadPrepareRequestDto {
|
export class UploadPrepareRequestDto {
|
||||||
@ -72,7 +74,41 @@ export class UploadSessionStatusResponseDto {
|
|||||||
finalizedAt?: string;
|
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 {
|
export class UploadFinalizeRequestDto {
|
||||||
|
static readonly artworkMimeTypes = ['image/jpeg', 'image/png'] as const;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Track Title' })
|
@ApiProperty({ example: 'Track Title' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@ -94,6 +130,15 @@ export class UploadFinalizeRequestDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(Number.MAX_SAFE_INTEGER)
|
@Max(Number.MAX_SAFE_INTEGER)
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
type: () => UploadFinalizeArtworkDto,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => UploadFinalizeArtworkDto)
|
||||||
|
artwork?: UploadFinalizeArtworkDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UploadFinalizeResponseDto {
|
export class UploadFinalizeResponseDto {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ function createPrismaMock() {
|
|||||||
const devices = new Map<string, any>();
|
const devices = new Map<string, any>();
|
||||||
const tracks = new Map<string, any>();
|
const tracks = new Map<string, any>();
|
||||||
const audioAssets = new Map<string, any>();
|
const audioAssets = new Map<string, any>();
|
||||||
|
const artworkAssets = new Map<string, any>();
|
||||||
const uploadSessions = new Map<string, any>();
|
const uploadSessions = new Map<string, any>();
|
||||||
const libraryEvents = new Map<bigint, any>();
|
const libraryEvents = new Map<bigint, any>();
|
||||||
let nextLibraryEventId = 1n;
|
let nextLibraryEventId = 1n;
|
||||||
@ -145,6 +146,35 @@ function createPrismaMock() {
|
|||||||
return updated;
|
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: {
|
uploadSession: {
|
||||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -199,6 +229,7 @@ function createPrismaMock() {
|
|||||||
devices,
|
devices,
|
||||||
tracks,
|
tracks,
|
||||||
audioAssets,
|
audioAssets,
|
||||||
|
artworkAssets,
|
||||||
uploadSessions,
|
uploadSessions,
|
||||||
libraryEvents,
|
libraryEvents,
|
||||||
},
|
},
|
||||||
@ -238,6 +269,13 @@ function sha256Hex(data: Buffer): string {
|
|||||||
return createHash('sha256').update(data).digest('hex');
|
return createHash('sha256').update(data).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sampleArtworkBytes(): Buffer {
|
||||||
|
return Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe('UploadsService', () => {
|
describe('UploadsService', () => {
|
||||||
let prismaMock: any;
|
let prismaMock: any;
|
||||||
let state: MockState;
|
let state: MockState;
|
||||||
@ -369,6 +407,50 @@ describe('UploadsService', () => {
|
|||||||
expect(session.audioAssetId).toBe(finalizeResponse.assetId);
|
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 () => {
|
it('returns exists from prepare after a successful upload and finalize', async () => {
|
||||||
const device = seedDevice();
|
const device = seedDevice();
|
||||||
const uploadedBytes = sampleMp3Bytes('duplicate-handling');
|
const uploadedBytes = sampleMp3Bytes('duplicate-handling');
|
||||||
@ -399,7 +481,73 @@ describe('UploadsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(secondPrepare.status).toBe('exists');
|
expect(secondPrepare.status).toBe('exists');
|
||||||
|
expect(secondPrepare.uploadId).toBeDefined();
|
||||||
expect(secondPrepare.trackId).toBe(finalizeResponse.trackId);
|
expect(secondPrepare.trackId).toBe(finalizeResponse.trackId);
|
||||||
expect(secondPrepare.assetId).toBe(finalizeResponse.assetId);
|
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 type { Request } from 'express';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { constants } from 'node:fs';
|
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 { extname } from 'node:path';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
import {
|
import {
|
||||||
|
UploadFinalizeArtworkDto,
|
||||||
UploadFinalizeRequestDto,
|
UploadFinalizeRequestDto,
|
||||||
UploadFinalizeResponseDto,
|
UploadFinalizeResponseDto,
|
||||||
UploadPrepareRequestDto,
|
UploadPrepareRequestDto,
|
||||||
@ -25,6 +26,15 @@ import {
|
|||||||
UploadSessionStatusResponseDto,
|
UploadSessionStatusResponseDto,
|
||||||
} from './uploads.dto';
|
} from './uploads.dto';
|
||||||
|
|
||||||
|
interface PreparedArtworkAssetInput {
|
||||||
|
sha256: string;
|
||||||
|
mimeType: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
storageKey: string;
|
||||||
|
fileSizeBytes: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UploadsService {
|
export class UploadsService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -57,8 +67,28 @@ export class UploadsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existingAsset) {
|
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 {
|
return {
|
||||||
status: 'exists',
|
status: 'exists',
|
||||||
|
uploadId: uploadSession.id,
|
||||||
trackId: existingAsset.trackId ?? undefined,
|
trackId: existingAsset.trackId ?? undefined,
|
||||||
assetId: existingAsset.id,
|
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({
|
let audioAsset = await tx.audioAsset.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_sha256: {
|
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({
|
await tx.libraryEvent.create({
|
||||||
data: {
|
data: {
|
||||||
userId: currentSession.userId,
|
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 {
|
private assertMp3Filename(filename: string): void {
|
||||||
if (extname(filename).toLowerCase() !== '.mp3') {
|
if (extname(filename).toLowerCase() !== '.mp3') {
|
||||||
throw new UnprocessableEntityException('Only MP3 uploads are supported.');
|
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(
|
private async markUploadFailed(
|
||||||
uploadId: string,
|
uploadId: string,
|
||||||
receivedBytes: number,
|
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 {
|
private trimOptional(value?: string): string | undefined {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
|
|||||||
@ -5,12 +5,13 @@ import { dirname, join } from 'node:path';
|
|||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import {
|
import {
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
INestApplication,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
VersioningType,
|
VersioningType,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
|
||||||
import { AppModule } from '../../src/app.module';
|
import { AppModule } from '../../src/app.module';
|
||||||
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
import { AssetsController } from '../../src/modules/assets/assets.controller';
|
||||||
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
|
||||||
@ -218,6 +219,33 @@ function createPrismaMock() {
|
|||||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
return artworkAssets.get(where.id) ?? null;
|
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: {
|
uploadSession: {
|
||||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
@ -284,7 +312,7 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Velody API wiring (e2e)', () => {
|
describe('Velody API wiring (e2e)', () => {
|
||||||
let app: INestApplication;
|
let app: NestExpressApplication;
|
||||||
let assetsController: AssetsController;
|
let assetsController: AssetsController;
|
||||||
let artworkController: ArtworkController;
|
let artworkController: ArtworkController;
|
||||||
let healthController: HealthController;
|
let healthController: HealthController;
|
||||||
@ -314,7 +342,12 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
.useValue(prismaMock)
|
.useValue(prismaMock)
|
||||||
.compile();
|
.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.setGlobalPrefix('api');
|
||||||
app.enableVersioning({ type: VersioningType.URI });
|
app.enableVersioning({ type: VersioningType.URI });
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
@ -515,10 +548,16 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==',
|
||||||
'base64',
|
'base64',
|
||||||
);
|
);
|
||||||
const storageKey = join('library', 'artwork', `${artworkId}.png`);
|
const storageKey = join(
|
||||||
|
'users',
|
||||||
|
prismaState.defaultUser.id,
|
||||||
|
'artwork',
|
||||||
|
`${sha256Hex(bytes)}.png`,
|
||||||
|
);
|
||||||
|
|
||||||
prismaState.artworkAssets.set(artworkId, {
|
prismaState.artworkAssets.set(artworkId, {
|
||||||
id: artworkId,
|
id: artworkId,
|
||||||
|
userId: prismaState.defaultUser.id,
|
||||||
sha256: sha256Hex(bytes),
|
sha256: sha256Hex(bytes),
|
||||||
storageKey,
|
storageKey,
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
@ -526,9 +565,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
height: 1,
|
height: 1,
|
||||||
fileSizeBytes: BigInt(bytes.length),
|
fileSizeBytes: BigInt(bytes.length),
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
track: {
|
tracks: [{ userId: prismaState.defaultUser.id }],
|
||||||
userId: prismaState.defaultUser.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
prismaState.tracks.set(trackId, {
|
prismaState.tracks.set(trackId, {
|
||||||
id: trackId,
|
id: trackId,
|
||||||
@ -583,16 +620,20 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
|
|
||||||
prismaState.artworkAssets.set(artworkId, {
|
prismaState.artworkAssets.set(artworkId, {
|
||||||
id: artworkId,
|
id: artworkId,
|
||||||
|
userId: prismaState.defaultUser.id,
|
||||||
sha256: 'sha-missing-artwork',
|
sha256: 'sha-missing-artwork',
|
||||||
storageKey: join('library', 'artwork', `${artworkId}.png`),
|
storageKey: join(
|
||||||
|
'users',
|
||||||
|
prismaState.defaultUser.id,
|
||||||
|
'artwork',
|
||||||
|
'sha-missing-artwork.png',
|
||||||
|
),
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
fileSizeBytes: BigInt(10),
|
fileSizeBytes: BigInt(10),
|
||||||
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
createdAt: new Date('2026-05-29T08:00:00.000Z'),
|
||||||
track: {
|
tracks: [{ userId: prismaState.defaultUser.id }],
|
||||||
userId: prismaState.defaultUser.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@ -638,6 +679,8 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
const primaryTrackId = randomUUID();
|
const primaryTrackId = randomUUID();
|
||||||
const primaryAssetId = randomUUID();
|
const primaryAssetId = randomUUID();
|
||||||
const primaryArtworkId = randomUUID();
|
const primaryArtworkId = randomUUID();
|
||||||
|
const placeholderTrackId = randomUUID();
|
||||||
|
const placeholderAssetId = randomUUID();
|
||||||
const secondaryTrackId = randomUUID();
|
const secondaryTrackId = randomUUID();
|
||||||
const secondaryAssetId = randomUUID();
|
const secondaryAssetId = randomUUID();
|
||||||
|
|
||||||
@ -669,13 +712,29 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
prismaState.artworkAssets.set(primaryArtworkId, {
|
prismaState.artworkAssets.set(primaryArtworkId, {
|
||||||
id: primaryArtworkId,
|
id: primaryArtworkId,
|
||||||
|
userId: prismaState.defaultUser.id,
|
||||||
sha256: 'artwork-sha-default',
|
sha256: 'artwork-sha-default',
|
||||||
storageKey: `library/artwork/${primaryArtworkId}.png`,
|
storageKey: `users/${prismaState.defaultUser.id}/artwork/artwork-sha-default.png`,
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
width: 512,
|
width: 512,
|
||||||
height: 512,
|
height: 512,
|
||||||
fileSizeBytes: BigInt(128),
|
fileSizeBytes: BigInt(128),
|
||||||
createdAt: new Date('2026-05-29T08:00:30.000Z'),
|
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, {
|
prismaState.audioAssets.set(secondaryAssetId, {
|
||||||
id: secondaryAssetId,
|
id: secondaryAssetId,
|
||||||
@ -730,6 +789,25 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
createdAt: new Date('2026-05-29T08:01:00.000Z'),
|
createdAt: new Date('2026-05-29T08:01:00.000Z'),
|
||||||
updatedAt: new Date('2026-05-29T08:03: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({
|
const response = await libraryController.getTracks({
|
||||||
deviceId: primaryDevice.deviceId,
|
deviceId: primaryDevice.deviceId,
|
||||||
@ -754,6 +832,17 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
height: 512,
|
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.status).toBe('exists');
|
||||||
|
expect(duplicatePrepare.uploadId).toBeDefined();
|
||||||
expect(prismaState.audioAssets.size).toBe(1);
|
expect(prismaState.audioAssets.size).toBe(1);
|
||||||
expect(prismaState.libraryEvents.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
|
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 struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public var title: String
|
public var title: String
|
||||||
@ -21,6 +43,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
public var durationSeconds: Double?
|
public var durationSeconds: Double?
|
||||||
public var localFilePath: String
|
public var localFilePath: String
|
||||||
public var sha256: String?
|
public var sha256: String?
|
||||||
|
public var artwork: LocalTrackArtwork?
|
||||||
public var uploadStatus: LocalUploadStatus?
|
public var uploadStatus: LocalUploadStatus?
|
||||||
public var remoteTrackId: String?
|
public var remoteTrackId: String?
|
||||||
public var lastUploadError: String?
|
public var lastUploadError: String?
|
||||||
@ -33,6 +56,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
durationSeconds: Double? = nil,
|
durationSeconds: Double? = nil,
|
||||||
localFilePath: String = "",
|
localFilePath: String = "",
|
||||||
sha256: String? = nil,
|
sha256: String? = nil,
|
||||||
|
artwork: LocalTrackArtwork? = nil,
|
||||||
uploadStatus: LocalUploadStatus? = nil,
|
uploadStatus: LocalUploadStatus? = nil,
|
||||||
remoteTrackId: String? = nil,
|
remoteTrackId: String? = nil,
|
||||||
lastUploadError: String? = nil
|
lastUploadError: String? = nil
|
||||||
@ -44,6 +68,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
self.durationSeconds = durationSeconds
|
self.durationSeconds = durationSeconds
|
||||||
self.localFilePath = localFilePath
|
self.localFilePath = localFilePath
|
||||||
self.sha256 = sha256
|
self.sha256 = sha256
|
||||||
|
self.artwork = artwork
|
||||||
self.uploadStatus = uploadStatus
|
self.uploadStatus = uploadStatus
|
||||||
self.remoteTrackId = remoteTrackId
|
self.remoteTrackId = remoteTrackId
|
||||||
self.lastUploadError = lastUploadError
|
self.lastUploadError = lastUploadError
|
||||||
@ -187,21 +212,46 @@ public struct UploadSessionStatusResponse: Codable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct UploadFinalizeRequest: 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 title: String
|
||||||
public var artist: String
|
public var artist: String
|
||||||
public var album: String?
|
public var album: String?
|
||||||
public var durationMs: Int?
|
public var durationMs: Int?
|
||||||
|
public var artwork: ArtworkPayload?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
title: String,
|
title: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
album: String? = nil,
|
album: String? = nil,
|
||||||
durationMs: Int? = nil
|
durationMs: Int? = nil,
|
||||||
|
artwork: ArtworkPayload? = nil
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.artist = artist
|
self.artist = artist
|
||||||
self.album = album
|
self.album = album
|
||||||
self.durationMs = durationMs
|
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.durationSeconds != scannedTrack.durationSeconds
|
||||||
|| existingTrack.localFilePath != scannedTrack.localFilePath
|
|| existingTrack.localFilePath != scannedTrack.localFilePath
|
||||||
|| existingTrack.sha256 != scannedTrack.sha256
|
|| existingTrack.sha256 != scannedTrack.sha256
|
||||||
|
|| existingTrack.artwork != scannedTrack.artwork
|
||||||
|| existingTrack.fileModifiedAt != scannedTrack.fileModifiedAt
|
|| existingTrack.fileModifiedAt != scannedTrack.fileModifiedAt
|
||||||
|| existingTrack.isDeleted
|
|| existingTrack.isDeleted
|
||||||
|
|
||||||
@ -198,6 +199,7 @@ public actor DefaultLocalCatalogService: LocalCatalogService {
|
|||||||
updatedTrack.durationSeconds = scannedTrack.durationSeconds
|
updatedTrack.durationSeconds = scannedTrack.durationSeconds
|
||||||
updatedTrack.localFilePath = scannedTrack.localFilePath
|
updatedTrack.localFilePath = scannedTrack.localFilePath
|
||||||
updatedTrack.sha256 = scannedTrack.sha256
|
updatedTrack.sha256 = scannedTrack.sha256
|
||||||
|
updatedTrack.artwork = scannedTrack.artwork
|
||||||
updatedTrack.fileModifiedAt = scannedTrack.fileModifiedAt
|
updatedTrack.fileModifiedAt = scannedTrack.fileModifiedAt
|
||||||
updatedTrack.lastScannedAt = scannedAt
|
updatedTrack.lastScannedAt = scannedAt
|
||||||
updatedTrack.isDeleted = false
|
updatedTrack.isDeleted = false
|
||||||
|
|||||||
@ -2,21 +2,46 @@ import Foundation
|
|||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
|
|
||||||
public struct LocalTrackMetadata: Hashable, Sendable {
|
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 title: String?
|
||||||
public var artist: String?
|
public var artist: String?
|
||||||
public var album: String?
|
public var album: String?
|
||||||
public var durationSeconds: Double?
|
public var durationSeconds: Double?
|
||||||
|
public var artwork: EmbeddedArtworkPayload?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
title: String? = nil,
|
title: String? = nil,
|
||||||
artist: String? = nil,
|
artist: String? = nil,
|
||||||
album: String? = nil,
|
album: String? = nil,
|
||||||
durationSeconds: Double? = nil
|
durationSeconds: Double? = nil,
|
||||||
|
artwork: EmbeddedArtworkPayload? = nil
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.artist = artist
|
self.artist = artist
|
||||||
self.album = album
|
self.album = album
|
||||||
self.durationSeconds = durationSeconds
|
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 durationSeconds: Double?
|
||||||
public var localFilePath: String
|
public var localFilePath: String
|
||||||
public var sha256: String?
|
public var sha256: String?
|
||||||
|
public var artwork: LocalTrackArtwork?
|
||||||
public var uploadStatus: LocalUploadStatus
|
public var uploadStatus: LocalUploadStatus
|
||||||
public var remoteTrackId: String?
|
public var remoteTrackId: String?
|
||||||
public var lastUploadError: String?
|
public var lastUploadError: String?
|
||||||
@ -34,6 +35,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
durationSeconds: Double? = nil,
|
durationSeconds: Double? = nil,
|
||||||
localFilePath: String = "",
|
localFilePath: String = "",
|
||||||
sha256: String? = nil,
|
sha256: String? = nil,
|
||||||
|
artwork: LocalTrackArtwork? = nil,
|
||||||
uploadStatus: LocalUploadStatus = .localOnly,
|
uploadStatus: LocalUploadStatus = .localOnly,
|
||||||
remoteTrackId: String? = nil,
|
remoteTrackId: String? = nil,
|
||||||
lastUploadError: String? = nil,
|
lastUploadError: String? = nil,
|
||||||
@ -52,6 +54,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
self.durationSeconds = durationSeconds
|
self.durationSeconds = durationSeconds
|
||||||
self.localFilePath = localFilePath
|
self.localFilePath = localFilePath
|
||||||
self.sha256 = sha256
|
self.sha256 = sha256
|
||||||
|
self.artwork = artwork
|
||||||
self.uploadStatus = uploadStatus
|
self.uploadStatus = uploadStatus
|
||||||
self.remoteTrackId = remoteTrackId
|
self.remoteTrackId = remoteTrackId
|
||||||
self.lastUploadError = lastUploadError
|
self.lastUploadError = lastUploadError
|
||||||
@ -77,6 +80,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
durationSeconds: scannedTrack.durationSeconds,
|
durationSeconds: scannedTrack.durationSeconds,
|
||||||
localFilePath: scannedTrack.localFilePath,
|
localFilePath: scannedTrack.localFilePath,
|
||||||
sha256: scannedTrack.sha256,
|
sha256: scannedTrack.sha256,
|
||||||
|
artwork: scannedTrack.artwork,
|
||||||
uploadStatus: .localOnly,
|
uploadStatus: .localOnly,
|
||||||
remoteTrackId: nil,
|
remoteTrackId: nil,
|
||||||
lastUploadError: nil,
|
lastUploadError: nil,
|
||||||
@ -103,6 +107,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
durationSeconds: libraryTrack.durationSeconds,
|
durationSeconds: libraryTrack.durationSeconds,
|
||||||
localFilePath: libraryTrack.localFilePath,
|
localFilePath: libraryTrack.localFilePath,
|
||||||
sha256: libraryTrack.sha256,
|
sha256: libraryTrack.sha256,
|
||||||
|
artwork: libraryTrack.artwork,
|
||||||
uploadStatus: libraryTrack.uploadStatus ?? .localOnly,
|
uploadStatus: libraryTrack.uploadStatus ?? .localOnly,
|
||||||
remoteTrackId: libraryTrack.remoteTrackId,
|
remoteTrackId: libraryTrack.remoteTrackId,
|
||||||
lastUploadError: libraryTrack.lastUploadError,
|
lastUploadError: libraryTrack.lastUploadError,
|
||||||
@ -132,6 +137,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
durationSeconds: durationSeconds,
|
durationSeconds: durationSeconds,
|
||||||
localFilePath: localFilePath,
|
localFilePath: localFilePath,
|
||||||
sha256: sha256,
|
sha256: sha256,
|
||||||
|
artwork: artwork,
|
||||||
uploadStatus: uploadStatus,
|
uploadStatus: uploadStatus,
|
||||||
remoteTrackId: remoteTrackId,
|
remoteTrackId: remoteTrackId,
|
||||||
lastUploadError: lastUploadError
|
lastUploadError: lastUploadError
|
||||||
@ -168,6 +174,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable {
|
|||||||
public var durationSeconds: Double?
|
public var durationSeconds: Double?
|
||||||
public var localFilePath: String
|
public var localFilePath: String
|
||||||
public var sha256: String
|
public var sha256: String
|
||||||
|
public var artwork: LocalTrackArtwork?
|
||||||
public var fileModifiedAt: Date
|
public var fileModifiedAt: Date
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@ -177,6 +184,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable {
|
|||||||
durationSeconds: Double? = nil,
|
durationSeconds: Double? = nil,
|
||||||
localFilePath: String,
|
localFilePath: String,
|
||||||
sha256: String,
|
sha256: String,
|
||||||
|
artwork: LocalTrackArtwork? = nil,
|
||||||
fileModifiedAt: Date
|
fileModifiedAt: Date
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
@ -185,6 +193,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable {
|
|||||||
self.durationSeconds = durationSeconds
|
self.durationSeconds = durationSeconds
|
||||||
self.localFilePath = localFilePath
|
self.localFilePath = localFilePath
|
||||||
self.sha256 = sha256
|
self.sha256 = sha256
|
||||||
|
self.artwork = artwork
|
||||||
self.fileModifiedAt = fileModifiedAt
|
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 durationSeconds: Double?
|
||||||
var localFilePath: String
|
var localFilePath: String
|
||||||
var sha256: String?
|
var sha256: String?
|
||||||
|
var localArtworkFilePath: String?
|
||||||
|
var artworkSHA256: String?
|
||||||
|
var artworkMimeType: String?
|
||||||
|
var artworkWidth: Int?
|
||||||
|
var artworkHeight: Int?
|
||||||
var uploadStatusRawValue: String?
|
var uploadStatusRawValue: String?
|
||||||
var remoteTrackID: String?
|
var remoteTrackID: String?
|
||||||
var lastUploadError: String?
|
var lastUploadError: String?
|
||||||
@ -33,6 +38,11 @@ final class TrackEntity {
|
|||||||
durationSeconds = track.durationSeconds
|
durationSeconds = track.durationSeconds
|
||||||
localFilePath = track.localFilePath
|
localFilePath = track.localFilePath
|
||||||
sha256 = track.sha256
|
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
|
uploadStatusRawValue = track.uploadStatus.rawValue
|
||||||
remoteTrackID = track.remoteTrackId
|
remoteTrackID = track.remoteTrackId
|
||||||
lastUploadError = track.lastUploadError
|
lastUploadError = track.lastUploadError
|
||||||
@ -54,6 +64,7 @@ final class TrackEntity {
|
|||||||
durationSeconds: durationSeconds,
|
durationSeconds: durationSeconds,
|
||||||
localFilePath: localFilePath,
|
localFilePath: localFilePath,
|
||||||
sha256: sha256,
|
sha256: sha256,
|
||||||
|
artwork: localArtwork,
|
||||||
uploadStatus: LocalUploadStatus(rawValue: uploadStatusRawValue ?? "") ?? .localOnly,
|
uploadStatus: LocalUploadStatus(rawValue: uploadStatusRawValue ?? "") ?? .localOnly,
|
||||||
remoteTrackId: remoteTrackID,
|
remoteTrackId: remoteTrackID,
|
||||||
lastUploadError: lastUploadError,
|
lastUploadError: lastUploadError,
|
||||||
@ -76,6 +87,11 @@ final class TrackEntity {
|
|||||||
durationSeconds = track.durationSeconds
|
durationSeconds = track.durationSeconds
|
||||||
localFilePath = track.localFilePath
|
localFilePath = track.localFilePath
|
||||||
sha256 = track.sha256
|
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
|
uploadStatusRawValue = track.uploadStatus.rawValue
|
||||||
remoteTrackID = track.remoteTrackId
|
remoteTrackID = track.remoteTrackId
|
||||||
lastUploadError = track.lastUploadError
|
lastUploadError = track.lastUploadError
|
||||||
@ -86,4 +102,21 @@ final class TrackEntity {
|
|||||||
createdAt = track.createdAt
|
createdAt = track.createdAt
|
||||||
updatedAt = track.updatedAt
|
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")
|
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 {
|
func testRescanMarksDeletedTracksAndReactivatesExistingSHA() async throws {
|
||||||
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
||||||
let service = DefaultLocalCatalogService(repository: repository)
|
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