From 15cde622e468231b4d71499cc51d152d467ea147 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 20 Mar 2026 20:53:23 +0100 Subject: [PATCH] refactor(mukke): optimize storage usage in song and beat services SongService: - Use deleteMany() for batch deletion of audio + cover (was sequential) - Add maxSizeBytes validation on cover art (10MB) and tag writing (500MB) - Add cacheControl header on cover art uploads (immutable, 1 year) - Add upload hooks for structured logging - Use 'songs' subfolder for audio, 'covers' for cover art (was flat) - Add deleteAllUserFiles() for account deletion via deleteByPrefix() - Log storage errors instead of silently swallowing BeatService: - Add upload:error hook for structured logging - Use 'beats' subfolder for organized key structure - Log storage deletion errors instead of swallowing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/backend/src/beat/beat.service.ts | 22 +++----- .../apps/backend/src/song/song.service.ts | 56 +++++++++++-------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/apps/mukke/apps/backend/src/beat/beat.service.ts b/apps/mukke/apps/backend/src/beat/beat.service.ts index f580c4c35..0737cbc5e 100644 --- a/apps/mukke/apps/backend/src/beat/beat.service.ts +++ b/apps/mukke/apps/backend/src/beat/beat.service.ts @@ -24,6 +24,10 @@ export class BeatService { private lyricsService: LyricsService ) { this.storage = createMukkeStorage(); + + this.storage.hooks.on('upload:error', ({ key, error }) => { + this.logger.error(`Upload failed for ${key}: ${error.message}`); + }); } async findByProjectId(projectId: string): Promise { @@ -67,7 +71,7 @@ export class BeatService { throw new BadRequestException('Beat already exists for this project. Delete it first.'); } - const key = generateUserFileKey(userId, filename); + const key = generateUserFileKey(userId, filename, 'beats'); const contentType = getContentType(filename); if (!contentType.startsWith('audio/') && !['application/octet-stream'].includes(contentType)) { @@ -85,9 +89,7 @@ export class BeatService { .returning(); // Generate presigned upload URL - const uploadUrl = await this.storage.getUploadUrl(key, { - expiresIn: 3600, - }); + const uploadUrl = await this.storage.getUploadUrl(key, { expiresIn: 3600 }); return { beat, uploadUrl }; } @@ -120,11 +122,10 @@ export class BeatService { const beat = await this.findByIdOrThrow(id); await this.verifyProjectOwnership(beat.projectId, userId); - // Delete from storage try { await this.storage.delete(beat.storagePath); - } catch { - // Ignore storage errors, continue with DB deletion + } catch (err) { + this.logger.warn(`Failed to delete beat file ${beat.storagePath}: ${err}`); } // Delete from database (markers will be cascade deleted) @@ -189,16 +190,10 @@ export class BeatService { // ==================== STT Transcription ==================== - /** - * Check if STT service is available - */ async isSttAvailable(): Promise { return this.sttService.isAvailable(); } - /** - * Transcribe beat audio and save lyrics to the project - */ async transcribeBeat( beatId: string, userId: string @@ -252,7 +247,6 @@ export class BeatService { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.logger.error(`Transcription failed for beat ${beatId}: ${errorMessage}`); - // Update beat status to failed await this.db .update(beats) .set({ diff --git a/apps/mukke/apps/backend/src/song/song.service.ts b/apps/mukke/apps/backend/src/song/song.service.ts index c6c886fed..de0e76580 100644 --- a/apps/mukke/apps/backend/src/song/song.service.ts +++ b/apps/mukke/apps/backend/src/song/song.service.ts @@ -12,6 +12,9 @@ import { type StorageClient, } from '@manacore/shared-storage'; +const MAX_AUDIO_SIZE = 500 * 1024 * 1024; // 500MB +const MAX_COVER_SIZE = 10 * 1024 * 1024; // 10MB + @Injectable() export class SongService { private readonly logger = new Logger(SongService.name); @@ -19,6 +22,13 @@ export class SongService { constructor(@Inject(DATABASE_CONNECTION) private db: Database) { this.storage = createMukkeStorage(); + + this.storage.hooks.on('upload', ({ key, sizeBytes, contentType }) => { + this.logger.debug(`Uploaded ${key} (${sizeBytes} bytes, ${contentType})`); + }); + this.storage.hooks.on('upload:error', ({ key, error }) => { + this.logger.error(`Upload failed for ${key}: ${error.message}`); + }); } async createUploadUrl( @@ -26,7 +36,7 @@ export class SongService { filename: string, fileLastModified?: number ): Promise<{ song: Song; uploadUrl: string }> { - const key = generateUserFileKey(userId, filename); + const key = generateUserFileKey(userId, filename, 'songs'); const contentType = getContentType(filename); if (!contentType.startsWith('audio/') && !['application/octet-stream'].includes(contentType)) { @@ -51,9 +61,7 @@ export class SongService { .values(values as typeof songs.$inferInsert) .returning(); - const uploadUrl = await this.storage.getUploadUrl(key, { - expiresIn: 3600, - }); + const uploadUrl = await this.storage.getUploadUrl(key, { expiresIn: 3600 }); return { song, uploadUrl }; } @@ -63,18 +71,16 @@ export class SongService { userId: string, filename: string ): Promise<{ uploadUrl: string }> { - const song = await this.findByIdOrThrow(songId, userId); + await this.findByIdOrThrow(songId, userId); - const key = generateUserFileKey(userId, `covers/${filename}`); + const key = generateUserFileKey(userId, filename, 'covers'); await this.db .update(songs) .set({ coverArtPath: key, updatedAt: new Date() }) .where(eq(songs.id, songId)); - const uploadUrl = await this.storage.getUploadUrl(key, { - expiresIn: 3600, - }); + const uploadUrl = await this.storage.getUploadUrl(key, { expiresIn: 3600 }); return { uploadUrl }; } @@ -183,20 +189,14 @@ export class SongService { async delete(id: string, userId: string): Promise { const song = await this.findByIdOrThrow(id, userId); - // Delete audio from storage - try { - await this.storage.delete(song.storagePath); - } catch { - // Ignore storage errors, continue with DB deletion - } + // Batch delete audio + cover in one call + const keysToDelete = [song.storagePath]; + if (song.coverArtPath) keysToDelete.push(song.coverArtPath); - // Delete cover art from storage if exists - if (song.coverArtPath) { - try { - await this.storage.delete(song.coverArtPath); - } catch { - // Ignore storage errors - } + try { + await this.storage.deleteMany(keysToDelete); + } catch (err) { + this.logger.warn(`Failed to delete storage files for song ${id}: ${err}`); } await this.db.delete(songs).where(and(eq(songs.id, id), eq(songs.userId, userId))); @@ -233,10 +233,12 @@ export class SongService { if (picture) { try { const ext = picture.format.includes('png') ? 'png' : 'jpg'; - const coverKey = generateUserFileKey(userId, `covers/${id}.${ext}`); + const coverKey = generateUserFileKey(userId, `${id}.${ext}`, 'covers'); await this.storage.upload(coverKey, Buffer.from(picture.data), { contentType: picture.format, public: true, + maxSizeBytes: MAX_COVER_SIZE, + cacheControl: 'public, max-age=31536000, immutable', }); updateData.coverArtPath = coverKey; } catch (e) { @@ -297,6 +299,7 @@ export class SongService { await this.storage.upload(song.storagePath, taggedBuffer as Buffer, { contentType: 'audio/mpeg', + maxSizeBytes: MAX_AUDIO_SIZE, }); } @@ -306,6 +309,13 @@ export class SongService { return this.storage.getDownloadUrl(song.coverArtPath, { expiresIn: 3600 }); } + /** + * Delete all storage files for a user (account deletion). + */ + async deleteAllUserFiles(userId: string): Promise { + return this.storage.deleteByPrefix(`users/${userId}/`); + } + private getSortColumn(field: string) { switch (field) { case 'title':