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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 20:53:23 +01:00
parent 56c3086ec6
commit 15cde622e4
2 changed files with 41 additions and 37 deletions

View file

@ -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<Beat | null> {
@ -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<boolean> {
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({

View file

@ -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<void> {
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<number> {
return this.storage.deleteByPrefix(`users/${userId}/`);
}
private getSortColumn(field: string) {
switch (field) {
case 'title':