This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsOptional, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class ShareAiMusicArchiveToFeedDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 2200)
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsNotEmpty, IsString, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateAiMusicArchiveDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Length(1, 120)
|
||||||
|
title!: string;
|
||||||
|
}
|
||||||
@@ -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 { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||||
import { HlsPlaylistService } from '../../common/media/hls-playlist.service';
|
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 { TextToMusicDto } from './dto/text-to-music.dto';
|
||||||
|
import { UpdateAiMusicArchiveDto } from './dto/update-ai-music-archive.dto';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
@ApiTags('Media')
|
@ApiTags('Media')
|
||||||
@@ -43,4 +57,57 @@ export class MediaController {
|
|||||||
async generateMusicFromText(@CurrentUser() user: JwtPayload, @Body() dto: TextToMusicDto) {
|
async generateMusicFromText(@CurrentUser() user: JwtPayload, @Body() dto: TextToMusicDto) {
|
||||||
return this.mediaService.generateMusicFromText(user.sub, dto);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { PostsModule } from '../posts/posts.module';
|
||||||
import { MediaController } from './media.controller';
|
import { MediaController } from './media.controller';
|
||||||
import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service';
|
import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { MediaRepository } from './media.repository';
|
import { MediaRepository } from './media.repository';
|
||||||
|
import { AiMusicArchive, AiMusicArchiveSchema } from './schemas/ai-music-archive.schema';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{
|
||||||
|
name: AiMusicArchive.name,
|
||||||
|
schema: AiMusicArchiveSchema,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
PostsModule,
|
||||||
|
],
|
||||||
controllers: [MediaController],
|
controllers: [MediaController],
|
||||||
providers: [MediaService, MediaRepository, AiMusicPromptEnhancerService],
|
providers: [MediaService, MediaRepository, AiMusicPromptEnhancerService],
|
||||||
exports: [MediaService],
|
exports: [MediaService],
|
||||||
|
|||||||
@@ -1,4 +1,122 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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()
|
@Injectable()
|
||||||
export class MediaRepository {}
|
export class MediaRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(AiMusicArchive.name)
|
||||||
|
private readonly aiMusicArchiveModel: Model<AiMusicArchiveDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<AiMusicArchiveDocument> {
|
||||||
|
return this.aiMusicArchiveModel.create({
|
||||||
|
...payload,
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAiMusicArchiveItemsByUser(
|
||||||
|
userId: string,
|
||||||
|
skip: number,
|
||||||
|
limit: number,
|
||||||
|
): Promise<AiMusicArchiveDocument[]> {
|
||||||
|
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<number> {
|
||||||
|
return this.aiMusicArchiveModel
|
||||||
|
.countDocuments({
|
||||||
|
userId: new Types.ObjectId(userId),
|
||||||
|
isDeleted: { $ne: true },
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAiMusicArchiveItemByUser(
|
||||||
|
userId: string,
|
||||||
|
itemId: string,
|
||||||
|
): Promise<AiMusicArchiveDocument | null> {
|
||||||
|
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<AiMusicArchiveDocument | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
@@ -40,6 +40,8 @@ describe('MediaService', () => {
|
|||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
storageService?: Record<string, unknown>;
|
storageService?: Record<string, unknown>;
|
||||||
mediaProbeService?: Record<string, unknown>;
|
mediaProbeService?: Record<string, unknown>;
|
||||||
|
mediaRepository?: Record<string, jest.Mock>;
|
||||||
|
postsService?: Record<string, jest.Mock>;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const configService = {
|
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,
|
configService as any,
|
||||||
(overrides.storageService ?? {}) as any,
|
(overrides.storageService ?? {}) as any,
|
||||||
(overrides.mediaProbeService ?? {}) as any,
|
(overrides.mediaProbeService ?? {}) as any,
|
||||||
new AiMusicPromptEnhancerService(),
|
new AiMusicPromptEnhancerService(),
|
||||||
|
mediaRepository as any,
|
||||||
|
postsService as any,
|
||||||
) as any;
|
) as any;
|
||||||
|
|
||||||
|
service.__mocks = { mediaRepository, postsService };
|
||||||
|
return service;
|
||||||
};
|
};
|
||||||
|
|
||||||
it('falls back to default Google application credentials when base64 JSON is missing', () => {
|
it('falls back to default Google application credentials when base64 JSON is missing', () => {
|
||||||
@@ -140,6 +167,7 @@ describe('MediaService', () => {
|
|||||||
vocals: false,
|
vocals: false,
|
||||||
});
|
});
|
||||||
expect(result.audioUrl).toBe('/uploads/ai-music/generated.wav');
|
expect(result.audioUrl).toBe('/uploads/ai-music/generated.wav');
|
||||||
|
expect(result.archiveItemId).toBe('archive-1');
|
||||||
expect(result.mimeType).toBe('audio/wav');
|
expect(result.mimeType).toBe('audio/wav');
|
||||||
expect(result.sizeBytes).toBe(audioBuffer.length);
|
expect(result.sizeBytes).toBe(audioBuffer.length);
|
||||||
expect(result.waveformPeaks).toHaveLength(160);
|
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 () => {
|
it('sends the enhanced Arabic prompt to Lyria while preserving the original prompt', async () => {
|
||||||
const audioBuffer = createPcmWavBuffer();
|
const audioBuffer = createPcmWavBuffer();
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
@@ -284,3 +546,40 @@ describe('MediaService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createArchiveDoc = (
|
||||||
|
overrides: Partial<Record<string, unknown>> = {},
|
||||||
|
): {
|
||||||
|
id: string;
|
||||||
|
audioUrl: string;
|
||||||
|
tag: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
waveformPeaks: number[];
|
||||||
|
toObject: () => Record<string, unknown>;
|
||||||
|
} => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 { ConfigService } from '@nestjs/config';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import { GoogleAuth } from 'google-auth-library';
|
import { GoogleAuth } from 'google-auth-library';
|
||||||
import { generateWaveformPeakSetFromBuffer } from '../../common/utils/waveform.util';
|
import { generateWaveformPeakSetFromBuffer } from '../../common/utils/waveform.util';
|
||||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||||
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
|
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
|
||||||
|
import { PostsService } from '../posts/posts.service';
|
||||||
import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.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 { 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()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
|
private readonly logger = new Logger(MediaService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly storageService: ManagedStorageService,
|
private readonly storageService: ManagedStorageService,
|
||||||
private readonly mediaProbeService: MediaProbeService,
|
private readonly mediaProbeService: MediaProbeService,
|
||||||
private readonly promptEnhancer: AiMusicPromptEnhancerService,
|
private readonly promptEnhancer: AiMusicPromptEnhancerService,
|
||||||
|
private readonly mediaRepository: MediaRepository,
|
||||||
|
private readonly postsService: PostsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getMediaHealth() {
|
async getMediaHealth() {
|
||||||
@@ -218,7 +238,7 @@ export class MediaService {
|
|||||||
actualDurationSeconds ?? dto.durationSeconds ?? 12,
|
actualDurationSeconds ?? dto.durationSeconds ?? 12,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const generationResult = {
|
||||||
prompt: dto.prompt,
|
prompt: dto.prompt,
|
||||||
originalPrompt: promptEnhancement.originalPrompt,
|
originalPrompt: promptEnhancement.originalPrompt,
|
||||||
enhancedPrompt: promptEnhancement.enhancedPrompt,
|
enhancedPrompt: promptEnhancement.enhancedPrompt,
|
||||||
@@ -229,6 +249,109 @@ export class MediaService {
|
|||||||
audioUrl,
|
audioUrl,
|
||||||
...waveformSet,
|
...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(
|
private async resolveSavedAudioDurationSeconds(
|
||||||
@@ -281,6 +404,54 @@ export class MediaService {
|
|||||||
return 'wav';
|
return 'wav';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findOwnedAiMusicArchiveItemOrFail(
|
||||||
|
userId: string,
|
||||||
|
itemId: string,
|
||||||
|
): Promise<AiMusicArchiveDocument> {
|
||||||
|
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<string, any>;
|
||||||
|
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<string>('publicBaseUrl', { infer: true }) ||
|
||||||
|
'';
|
||||||
|
return (configured || 'https://oudelaa.com').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
private resolveGoogleApplicationCredentials(): {
|
private resolveGoogleApplicationCredentials(): {
|
||||||
credentials?: any;
|
credentials?: any;
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -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<AiMusicArchive>;
|
||||||
|
|
||||||
|
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 });
|
||||||
Reference in New Issue
Block a user