mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 22:59:40 +02:00
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:
parent
56c3086ec6
commit
15cde622e4
2 changed files with 41 additions and 37 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue