From 7a754f4bf93cc6b39a3b280924810addd9323317 Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Tue, 30 Jun 2026 14:41:50 +0300 Subject: [PATCH] Add AI music archive backend --- .../media/dto/ai-music-archive-query.dto.ts | 17 + .../media/dto/share-ai-music-archive.dto.ts | 8 + .../media/dto/update-ai-music-archive.dto.ts | 8 + src/modules/media/media.controller.ts | 69 +++- src/modules/media/media.module.ts | 12 + src/modules/media/media.repository.ts | 120 ++++++- src/modules/media/media.service.spec.ts | 303 +++++++++++++++++- src/modules/media/media.service.ts | 175 +++++++++- .../media/schemas/ai-music-archive.schema.ts | 69 ++++ 9 files changed, 775 insertions(+), 6 deletions(-) create mode 100644 src/modules/media/dto/ai-music-archive-query.dto.ts create mode 100644 src/modules/media/dto/share-ai-music-archive.dto.ts create mode 100644 src/modules/media/dto/update-ai-music-archive.dto.ts create mode 100644 src/modules/media/schemas/ai-music-archive.schema.ts diff --git a/src/modules/media/dto/ai-music-archive-query.dto.ts b/src/modules/media/dto/ai-music-archive-query.dto.ts new file mode 100644 index 0000000..daa9acc --- /dev/null +++ b/src/modules/media/dto/ai-music-archive-query.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class AiMusicArchiveQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number = 20; +} diff --git a/src/modules/media/dto/share-ai-music-archive.dto.ts b/src/modules/media/dto/share-ai-music-archive.dto.ts new file mode 100644 index 0000000..9f3b910 --- /dev/null +++ b/src/modules/media/dto/share-ai-music-archive.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString, Length } from 'class-validator'; + +export class ShareAiMusicArchiveToFeedDto { + @IsOptional() + @IsString() + @Length(0, 2200) + content?: string; +} diff --git a/src/modules/media/dto/update-ai-music-archive.dto.ts b/src/modules/media/dto/update-ai-music-archive.dto.ts new file mode 100644 index 0000000..563000a --- /dev/null +++ b/src/modules/media/dto/update-ai-music-archive.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, Length } from 'class-validator'; + +export class UpdateAiMusicArchiveDto { + @IsString() + @IsNotEmpty() + @Length(1, 120) + title!: string; +} diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts index 0ae3615..b669e13 100644 --- a/src/modules/media/media.controller.ts +++ b/src/modules/media/media.controller.ts @@ -1,4 +1,15 @@ -import { Body, Controller, Get, Param, Post, Res, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Res, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { Throttle } from '../../common/decorators/throttle.decorator'; @@ -6,7 +17,10 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; import { HlsPlaylistService } from '../../common/media/hls-playlist.service'; +import { AiMusicArchiveQueryDto } from './dto/ai-music-archive-query.dto'; +import { ShareAiMusicArchiveToFeedDto } from './dto/share-ai-music-archive.dto'; import { TextToMusicDto } from './dto/text-to-music.dto'; +import { UpdateAiMusicArchiveDto } from './dto/update-ai-music-archive.dto'; import { MediaService } from './media.service'; @ApiTags('Media') @@ -43,4 +57,57 @@ export class MediaController { async generateMusicFromText(@CurrentUser() user: JwtPayload, @Body() dto: TextToMusicDto) { return this.mediaService.generateMusicFromText(user.sub, dto); } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('ai/music/archive') + async getMyAiMusicArchive( + @CurrentUser() user: JwtPayload, + @Query() query: AiMusicArchiveQueryDto, + ) { + return this.mediaService.getMyAiMusicArchive(user.sub, query); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('ai/music/archive/:id') + async getAiMusicArchiveItem(@CurrentUser() user: JwtPayload, @Param('id') itemId: string) { + return this.mediaService.getAiMusicArchiveItem(user.sub, itemId); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Patch('ai/music/archive/:id') + async renameAiMusicArchiveItem( + @CurrentUser() user: JwtPayload, + @Param('id') itemId: string, + @Body() dto: UpdateAiMusicArchiveDto, + ) { + return this.mediaService.renameAiMusicArchiveItem(user.sub, itemId, dto); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Delete('ai/music/archive/:id') + async deleteAiMusicArchiveItem(@CurrentUser() user: JwtPayload, @Param('id') itemId: string) { + return this.mediaService.deleteAiMusicArchiveItem(user.sub, itemId); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('ai/music/archive/:id/share') + async shareAiMusicArchiveItem(@CurrentUser() user: JwtPayload, @Param('id') itemId: string) { + return this.mediaService.shareAiMusicArchiveItem(user.sub, itemId); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('ai/music/archive/:id/share-to-feed') + async shareAiMusicArchiveItemToFeed( + @CurrentUser() user: JwtPayload, + @Param('id') itemId: string, + @Body() dto: ShareAiMusicArchiveToFeedDto, + ) { + return this.mediaService.shareAiMusicArchiveItemToFeed(user.sub, itemId, dto); + } } diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts index 9fec428..40d0fe3 100644 --- a/src/modules/media/media.module.ts +++ b/src/modules/media/media.module.ts @@ -1,10 +1,22 @@ import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { PostsModule } from '../posts/posts.module'; import { MediaController } from './media.controller'; import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; import { MediaService } from './media.service'; import { MediaRepository } from './media.repository'; +import { AiMusicArchive, AiMusicArchiveSchema } from './schemas/ai-music-archive.schema'; @Module({ + imports: [ + MongooseModule.forFeature([ + { + name: AiMusicArchive.name, + schema: AiMusicArchiveSchema, + }, + ]), + PostsModule, + ], controllers: [MediaController], providers: [MediaService, MediaRepository, AiMusicPromptEnhancerService], exports: [MediaService], diff --git a/src/modules/media/media.repository.ts b/src/modules/media/media.repository.ts index 36b1567..8165ed1 100644 --- a/src/modules/media/media.repository.ts +++ b/src/modules/media/media.repository.ts @@ -1,4 +1,122 @@ import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; +import { + AiMusicArchive, + AiMusicArchiveDocument, + AiMusicArchiveType, +} from './schemas/ai-music-archive.schema'; @Injectable() -export class MediaRepository {} +export class MediaRepository { + constructor( + @InjectModel(AiMusicArchive.name) + private readonly aiMusicArchiveModel: Model, + ) {} + + async createAiMusicArchiveItem( + userId: string, + payload: { + type: AiMusicArchiveType; + title: string; + prompt: string; + tag: string; + audioUrl: string; + storageKey?: string; + durationSeconds?: number | null; + mimeType?: string; + sizeBytes?: number; + waveformPeaks?: number[]; + }, + ): Promise { + return this.aiMusicArchiveModel.create({ + ...payload, + userId: new Types.ObjectId(userId), + isDeleted: false, + }); + } + + async findAiMusicArchiveItemsByUser( + userId: string, + skip: number, + limit: number, + ): Promise { + return this.aiMusicArchiveModel + .find({ + userId: new Types.ObjectId(userId), + isDeleted: { $ne: true }, + }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .exec(); + } + + async countAiMusicArchiveItemsByUser(userId: string): Promise { + return this.aiMusicArchiveModel + .countDocuments({ + userId: new Types.ObjectId(userId), + isDeleted: { $ne: true }, + }) + .exec(); + } + + async findAiMusicArchiveItemByUser( + userId: string, + itemId: string, + ): Promise { + if (!Types.ObjectId.isValid(itemId)) { + return null; + } + + return this.aiMusicArchiveModel + .findOne({ + _id: new Types.ObjectId(itemId), + userId: new Types.ObjectId(userId), + isDeleted: { $ne: true }, + }) + .exec(); + } + + async updateAiMusicArchiveTitle( + userId: string, + itemId: string, + title: string, + ): Promise { + if (!Types.ObjectId.isValid(itemId)) { + return null; + } + + return this.aiMusicArchiveModel + .findOneAndUpdate( + { + _id: new Types.ObjectId(itemId), + userId: new Types.ObjectId(userId), + isDeleted: { $ne: true }, + }, + { title }, + { new: true }, + ) + .exec(); + } + + async softDeleteAiMusicArchiveItem(userId: string, itemId: string): Promise { + if (!Types.ObjectId.isValid(itemId)) { + return false; + } + + const updated = await this.aiMusicArchiveModel + .findOneAndUpdate( + { + _id: new Types.ObjectId(itemId), + userId: new Types.ObjectId(userId), + isDeleted: { $ne: true }, + }, + { isDeleted: true }, + { new: false }, + ) + .exec(); + + return !!updated; + } +} diff --git a/src/modules/media/media.service.spec.ts b/src/modules/media/media.service.spec.ts index 3784066..a9afa0b 100644 --- a/src/modules/media/media.service.spec.ts +++ b/src/modules/media/media.service.spec.ts @@ -1,4 +1,4 @@ -import { ServiceUnavailableException } from '@nestjs/common'; +import { BadRequestException, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; import { MediaService } from './media.service'; @@ -40,6 +40,8 @@ describe('MediaService', () => { config?: Record; storageService?: Record; mediaProbeService?: Record; + mediaRepository?: Record; + postsService?: Record; } = {}, ) => { const configService = { @@ -54,12 +56,37 @@ describe('MediaService', () => { }), }; - return new MediaService( + const mediaRepository = { + createAiMusicArchiveItem: jest.fn().mockResolvedValue( + createArchiveDoc({ + id: 'archive-1', + title: 'test prompt', + prompt: 'test prompt', + audioUrl: '/uploads/ai-music/generated.wav', + }), + ), + findAiMusicArchiveItemsByUser: jest.fn(), + countAiMusicArchiveItemsByUser: jest.fn(), + findAiMusicArchiveItemByUser: jest.fn(), + updateAiMusicArchiveTitle: jest.fn(), + softDeleteAiMusicArchiveItem: jest.fn(), + ...(overrides.mediaRepository ?? {}), + }; + const postsService = { + create: jest.fn(), + ...(overrides.postsService ?? {}), + }; + const service = new MediaService( configService as any, (overrides.storageService ?? {}) as any, (overrides.mediaProbeService ?? {}) as any, new AiMusicPromptEnhancerService(), + mediaRepository as any, + postsService as any, ) as any; + + service.__mocks = { mediaRepository, postsService }; + return service; }; it('falls back to default Google application credentials when base64 JSON is missing', () => { @@ -140,6 +167,7 @@ describe('MediaService', () => { vocals: false, }); expect(result.audioUrl).toBe('/uploads/ai-music/generated.wav'); + expect(result.archiveItemId).toBe('archive-1'); expect(result.mimeType).toBe('audio/wav'); expect(result.sizeBytes).toBe(audioBuffer.length); expect(result.waveformPeaks).toHaveLength(160); @@ -166,6 +194,240 @@ describe('MediaService', () => { } }); + it('saves an archive item after successful text-to-music generation', async () => { + const audioBuffer = createPcmWavBuffer(); + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + predictions: [ + { + bytesBase64Encoded: audioBuffer.toString('base64'), + mimeType: 'audio/wav', + }, + ], + }), + }) as any; + const service = createService('', { + config: { + 'aiMusic.enabled': true, + 'aiMusic.apiKey': 'test-api-key', + 'aiMusic.projectId': 'accordev', + 'aiMusic.location': 'us-central1', + 'aiMusic.model': 'lyria-002', + }, + storageService: { + saveFile: jest.fn().mockResolvedValue('/uploads/ai-music/generated.wav'), + resolveLocalFilePath: jest.fn().mockReturnValue(null), + }, + mediaProbeService: { + extractDurationSeconds: jest.fn(), + extractDurationSecondsFromBuffer: jest.fn().mockResolvedValue(12), + }, + }); + + try { + const result = await service.generateMusicFromText('507f1f77bcf86cd799439011', { + prompt: 'Calm oud melody', + durationSeconds: 12, + }); + + expect(service.__mocks.mediaRepository.createAiMusicArchiveItem).toHaveBeenCalledWith( + '507f1f77bcf86cd799439011', + expect.objectContaining({ + type: 'text_to_music', + title: 'Calm oud melody', + prompt: 'Calm oud melody', + tag: 'text-to-music', + audioUrl: '/uploads/ai-music/generated.wav', + storageKey: '/uploads/ai-music/generated.wav', + durationSeconds: 12, + mimeType: 'audio/wav', + sizeBytes: audioBuffer.length, + }), + ); + expect(result.archiveItemId).toBe('archive-1'); + expect(result.archiveItem).toMatchObject({ + id: 'archive-1', + audioUrl: '/uploads/ai-music/generated.wav', + }); + } finally { + global.fetch = originalFetch; + } + }); + + it('does not save an archive item when generation fails', async () => { + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: jest.fn().mockResolvedValue({ error: { message: 'Lyria failed' } }), + }) as any; + const service = createService('', { + config: { + 'aiMusic.enabled': true, + 'aiMusic.apiKey': 'test-api-key', + 'aiMusic.projectId': 'accordev', + 'aiMusic.location': 'us-central1', + }, + }); + + try { + await expect( + service.generateMusicFromText('507f1f77bcf86cd799439011', { + prompt: 'Calm oud melody', + durationSeconds: 12, + }), + ).rejects.toThrow('Lyria failed'); + expect(service.__mocks.mediaRepository.createAiMusicArchiveItem).not.toHaveBeenCalled(); + } finally { + global.fetch = originalFetch; + } + }); + + it('returns only current user archive items with pagination', async () => { + const item = createArchiveDoc({ id: 'archive-1', title: 'Mine' }); + const service = createService('', { + mediaRepository: { + findAiMusicArchiveItemsByUser: jest.fn().mockResolvedValue([item]), + countAiMusicArchiveItemsByUser: jest.fn().mockResolvedValue(3), + }, + }); + + const result = await service.getMyAiMusicArchive('507f1f77bcf86cd799439011', { + page: 1, + limit: 1, + }); + + expect(service.__mocks.mediaRepository.findAiMusicArchiveItemsByUser).toHaveBeenCalledWith( + '507f1f77bcf86cd799439011', + 0, + 1, + ); + expect(result.items).toHaveLength(1); + expect(result.pagination).toEqual({ page: 1, limit: 1, hasMore: true }); + }); + + it('prevents access to another user archive item by returning 404', async () => { + const service = createService('', { + mediaRepository: { + findAiMusicArchiveItemByUser: jest.fn().mockResolvedValue(null), + }, + }); + + await expect( + service.getAiMusicArchiveItem('507f1f77bcf86cd799439011', '507f191e810c19729de860ea'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('renames an archive item title', async () => { + const service = createService('', { + mediaRepository: { + updateAiMusicArchiveTitle: jest + .fn() + .mockResolvedValue(createArchiveDoc({ id: 'archive-1', title: 'New title' })), + }, + }); + + const result = await service.renameAiMusicArchiveItem( + '507f1f77bcf86cd799439011', + '507f191e810c19729de860ea', + { title: ' New title ' }, + ); + + expect(service.__mocks.mediaRepository.updateAiMusicArchiveTitle).toHaveBeenCalledWith( + '507f1f77bcf86cd799439011', + '507f191e810c19729de860ea', + 'New title', + ); + expect(result.title).toBe('New title'); + }); + + it('rejects an empty archive title', async () => { + const service = createService(); + + await expect( + service.renameAiMusicArchiveItem( + '507f1f77bcf86cd799439011', + '507f191e810c19729de860ea', + { title: ' ' }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('soft deletes an archive item and hides deleted items through repository filters', async () => { + const service = createService('', { + mediaRepository: { + softDeleteAiMusicArchiveItem: jest.fn().mockResolvedValue(true), + findAiMusicArchiveItemsByUser: jest.fn().mockResolvedValue([]), + countAiMusicArchiveItemsByUser: jest.fn().mockResolvedValue(0), + }, + }); + + await expect( + service.deleteAiMusicArchiveItem( + '507f1f77bcf86cd799439011', + '507f191e810c19729de860ea', + ), + ).resolves.toEqual({ message: 'Archive item deleted' }); + + const list = await service.getMyAiMusicArchive('507f1f77bcf86cd799439011', {}); + expect(list.items).toEqual([]); + }); + + it('returns a simple share payload', async () => { + const service = createService('', { + config: { publicBaseUrl: 'https://api.example.com' }, + mediaRepository: { + findAiMusicArchiveItemByUser: jest + .fn() + .mockResolvedValue(createArchiveDoc({ id: 'archive-1', title: 'Share me' })), + }, + }); + + const result = await service.shareAiMusicArchiveItem( + '507f1f77bcf86cd799439011', + '507f191e810c19729de860ea', + ); + + expect(result).toMatchObject({ + shareUrl: 'https://api.example.com/ai-music/archive/archive-1', + title: 'Share me', + audioUrl: '/uploads/ai-music/generated.wav', + }); + }); + + it('shares an archive item to the feed as an audio post', async () => { + const post = { id: 'post-1', audioUrl: '/uploads/ai-music/generated.wav' }; + const service = createService('', { + mediaRepository: { + findAiMusicArchiveItemByUser: jest + .fn() + .mockResolvedValue(createArchiveDoc({ id: 'archive-1', title: 'Feed song' })), + }, + postsService: { + create: jest.fn().mockResolvedValue(post), + }, + }); + + const result = await service.shareAiMusicArchiveItemToFeed( + '507f1f77bcf86cd799439011', + '507f191e810c19729de860ea', + { content: 'Listen to this' }, + ); + + expect(service.__mocks.postsService.create).toHaveBeenCalledWith( + '507f1f77bcf86cd799439011', + expect.objectContaining({ + content: 'Listen to this', + audioUrl: '/uploads/ai-music/generated.wav', + durationSeconds: 12, + waveformPeaks: [20, 44, 70], + style: 'text-to-music', + }), + ); + expect(result).toBe(post); + }); + it('sends the enhanced Arabic prompt to Lyria while preserving the original prompt', async () => { const audioBuffer = createPcmWavBuffer(); const originalFetch = global.fetch; @@ -284,3 +546,40 @@ describe('MediaService', () => { } }); }); + +const createArchiveDoc = ( + overrides: Partial> = {}, +): { + id: string; + audioUrl: string; + tag: string; + durationSeconds: number; + waveformPeaks: number[]; + toObject: () => Record; +} => { + const value = { + _id: overrides.id ?? 'archive-1', + id: overrides.id ?? 'archive-1', + type: 'text_to_music', + title: overrides.title ?? 'Untitled AI Music', + prompt: overrides.prompt ?? 'prompt', + tag: overrides.tag ?? 'text-to-music', + audioUrl: overrides.audioUrl ?? '/uploads/ai-music/generated.wav', + storageKey: overrides.storageKey ?? '/uploads/ai-music/generated.wav', + durationSeconds: overrides.durationSeconds ?? 12, + mimeType: overrides.mimeType ?? 'audio/wav', + sizeBytes: overrides.sizeBytes ?? 1234, + waveformPeaks: overrides.waveformPeaks ?? [20, 44, 70], + createdAt: overrides.createdAt ?? new Date('2026-06-30T10:00:00.000Z'), + updatedAt: overrides.updatedAt ?? new Date('2026-06-30T10:00:00.000Z'), + }; + + return { + id: String(value.id), + audioUrl: String(value.audioUrl), + tag: String(value.tag), + durationSeconds: Number(value.durationSeconds), + waveformPeaks: value.waveformPeaks as number[], + toObject: () => value, + }; +}; diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index b7b7650..7bf6bf0 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,20 +1,40 @@ -import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { + BadGatewayException, + BadRequestException, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { readFile } from 'fs/promises'; import { GoogleAuth } from 'google-auth-library'; import { generateWaveformPeakSetFromBuffer } from '../../common/utils/waveform.util'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { MediaProbeService } from '../../infrastructure/storage/media-probe.service'; +import { PostsService } from '../posts/posts.service'; import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; +import { AiMusicArchiveQueryDto } from './dto/ai-music-archive-query.dto'; +import { ShareAiMusicArchiveToFeedDto } from './dto/share-ai-music-archive.dto'; import { TextToMusicDto } from './dto/text-to-music.dto'; +import { UpdateAiMusicArchiveDto } from './dto/update-ai-music-archive.dto'; +import { MediaRepository } from './media.repository'; +import { + AiMusicArchiveDocument, + AiMusicArchiveType, +} from './schemas/ai-music-archive.schema'; @Injectable() export class MediaService { + private readonly logger = new Logger(MediaService.name); + constructor( private readonly configService: ConfigService, private readonly storageService: ManagedStorageService, private readonly mediaProbeService: MediaProbeService, private readonly promptEnhancer: AiMusicPromptEnhancerService, + private readonly mediaRepository: MediaRepository, + private readonly postsService: PostsService, ) {} async getMediaHealth() { @@ -218,7 +238,7 @@ export class MediaService { actualDurationSeconds ?? dto.durationSeconds ?? 12, ); - return { + const generationResult = { prompt: dto.prompt, originalPrompt: promptEnhancement.originalPrompt, enhancedPrompt: promptEnhancement.enhancedPrompt, @@ -229,6 +249,109 @@ export class MediaService { audioUrl, ...waveformSet, }; + + try { + const archiveItem = await this.mediaRepository.createAiMusicArchiveItem(userId, { + type: AiMusicArchiveType.TEXT_TO_MUSIC, + title: this.buildArchiveTitle(dto.prompt), + prompt: dto.prompt, + tag: 'text-to-music', + audioUrl, + storageKey: audioUrl, + durationSeconds: generationResult.durationSeconds, + mimeType, + sizeBytes: buffer.length, + waveformPeaks: generationResult.waveformPeaks, + }); + + return { + ...generationResult, + archiveItemId: archiveItem.id, + archiveItem: this.formatAiMusicArchiveItem(archiveItem), + }; + } catch (error) { + this.logger.warn( + `AI music archive save failed for user=${userId}: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + return generationResult; + } + } + + async getMyAiMusicArchive(userId: string, query: AiMusicArchiveQueryDto) { + const page = query.page ?? 1; + const limit = Math.min(query.limit ?? 20, 50); + const skip = (page - 1) * limit; + const [items, total] = await Promise.all([ + this.mediaRepository.findAiMusicArchiveItemsByUser(userId, skip, limit), + this.mediaRepository.countAiMusicArchiveItemsByUser(userId), + ]); + + return { + items: items.map((item) => this.formatAiMusicArchiveItem(item)), + pagination: { + page, + limit, + hasMore: skip + items.length < total, + }, + }; + } + + async getAiMusicArchiveItem(userId: string, itemId: string) { + const item = await this.findOwnedAiMusicArchiveItemOrFail(userId, itemId); + return this.formatAiMusicArchiveItem(item); + } + + async renameAiMusicArchiveItem(userId: string, itemId: string, dto: UpdateAiMusicArchiveDto) { + const title = dto.title.trim(); + if (!title) { + throw new BadRequestException('title must not be empty'); + } + + const item = await this.mediaRepository.updateAiMusicArchiveTitle(userId, itemId, title); + if (!item) { + throw new NotFoundException('Archive item not found'); + } + + return this.formatAiMusicArchiveItem(item); + } + + async deleteAiMusicArchiveItem(userId: string, itemId: string): Promise<{ message: string }> { + const deleted = await this.mediaRepository.softDeleteAiMusicArchiveItem(userId, itemId); + if (!deleted) { + throw new NotFoundException('Archive item not found'); + } + + return { message: 'Archive item deleted' }; + } + + async shareAiMusicArchiveItem(userId: string, itemId: string) { + const item = await this.findOwnedAiMusicArchiveItemOrFail(userId, itemId); + const formatted = this.formatAiMusicArchiveItem(item); + + return { + shareUrl: `${this.resolvePublicAppUrl()}/ai-music/archive/${encodeURIComponent(item.id)}`, + title: formatted.title, + audioUrl: formatted.audioUrl, + }; + } + + async shareAiMusicArchiveItemToFeed( + userId: string, + itemId: string, + dto: ShareAiMusicArchiveToFeedDto = {}, + ) { + const item = await this.findOwnedAiMusicArchiveItemOrFail(userId, itemId); + const formatted = this.formatAiMusicArchiveItem(item); + + return this.postsService.create(userId, { + content: dto.content?.trim() || formatted.title, + audioUrl: item.audioUrl, + durationSeconds: item.durationSeconds ?? undefined, + waveformPeaks: item.waveformPeaks, + style: item.tag, + }); } private async resolveSavedAudioDurationSeconds( @@ -281,6 +404,54 @@ export class MediaService { return 'wav'; } + private async findOwnedAiMusicArchiveItemOrFail( + userId: string, + itemId: string, + ): Promise { + const item = await this.mediaRepository.findAiMusicArchiveItemByUser(userId, itemId); + if (!item) { + throw new NotFoundException('Archive item not found'); + } + + return item; + } + + private formatAiMusicArchiveItem(item: AiMusicArchiveDocument) { + const value = item.toObject() as Record; + return { + id: value.id ?? value._id?.toString?.() ?? item.id, + type: value.type, + title: value.title, + prompt: value.prompt, + tag: value.tag, + audioUrl: value.audioUrl, + storageKey: value.storageKey ?? '', + durationSeconds: value.durationSeconds ?? null, + mimeType: value.mimeType ?? '', + sizeBytes: value.sizeBytes ?? 0, + waveformPeaks: Array.isArray(value.waveformPeaks) ? value.waveformPeaks : [], + createdAt: value.createdAt, + updatedAt: value.updatedAt, + }; + } + + private buildArchiveTitle(prompt: string): string { + const compactPrompt = prompt.replace(/\s+/g, ' ').trim(); + if (!compactPrompt) { + return 'Untitled AI Music'; + } + + return compactPrompt.length > 80 ? `${compactPrompt.slice(0, 77)}...` : compactPrompt; + } + + private resolvePublicAppUrl(): string { + const configured = + process.env.PUBLIC_APP_URL?.trim() || + this.configService.get('publicBaseUrl', { infer: true }) || + ''; + return (configured || 'https://oudelaa.com').replace(/\/+$/, ''); + } + private resolveGoogleApplicationCredentials(): { credentials?: any; } { diff --git a/src/modules/media/schemas/ai-music-archive.schema.ts b/src/modules/media/schemas/ai-music-archive.schema.ts new file mode 100644 index 0000000..536da2c --- /dev/null +++ b/src/modules/media/schemas/ai-music-archive.schema.ts @@ -0,0 +1,69 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Types } from 'mongoose'; +import { resolveManagedFileUrl } from '../../../common/utils/public-url.util'; +import { User } from '../../users/schemas/user.schema'; + +export type AiMusicArchiveDocument = HydratedDocument; + +export enum AiMusicArchiveType { + TEXT_TO_MUSIC = 'text_to_music', + AUDIO_TO_MUSIC = 'audio_to_music', + OTHER = 'other', +} + +@Schema({ timestamps: true, versionKey: false }) +export class AiMusicArchive { + @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true }) + userId!: Types.ObjectId; + + @Prop({ + type: String, + enum: Object.values(AiMusicArchiveType), + default: AiMusicArchiveType.TEXT_TO_MUSIC, + index: true, + }) + type!: AiMusicArchiveType; + + @Prop({ required: true, trim: true, maxlength: 120 }) + title!: string; + + @Prop({ default: '', trim: true, maxlength: 500 }) + prompt!: string; + + @Prop({ default: '', trim: true, maxlength: 80 }) + tag!: string; + + @Prop({ required: true }) + audioUrl!: string; + + @Prop({ default: '' }) + storageKey!: string; + + @Prop({ type: Number, min: 0, default: null }) + durationSeconds!: number | null; + + @Prop({ default: '' }) + mimeType!: string; + + @Prop({ default: 0, min: 0 }) + sizeBytes!: number; + + @Prop({ type: [Number], default: [] }) + waveformPeaks!: number[]; + + @Prop({ default: false, index: true }) + isDeleted!: boolean; +} + +export const AiMusicArchiveSchema = SchemaFactory.createForClass(AiMusicArchive); + +AiMusicArchiveSchema.index({ userId: 1, isDeleted: 1, createdAt: -1 }); + +const transformManagedArchiveFiles = (_doc: unknown, ret: any) => { + ret.id = ret._id?.toString?.() ?? ret.id; + ret.audioUrl = resolveManagedFileUrl(ret.audioUrl); + return ret; +}; + +AiMusicArchiveSchema.set('toJSON', { transform: transformManagedArchiveFiles }); +AiMusicArchiveSchema.set('toObject', { transform: transformManagedArchiveFiles });