هذا الالتزام موجود في:
17
src/modules/media/dto/ai-music-archive-query.dto.ts
Normal file
17
src/modules/media/dto/ai-music-archive-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
8
src/modules/media/dto/share-ai-music-archive.dto.ts
Normal file
8
src/modules/media/dto/share-ai-music-archive.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class ShareAiMusicArchiveToFeedDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 2200)
|
||||
content?: string;
|
||||
}
|
||||
8
src/modules/media/dto/update-ai-music-archive.dto.ts
Normal file
8
src/modules/media/dto/update-ai-music-archive.dto.ts
Normal file
@@ -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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<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 { MediaService } from './media.service';
|
||||
|
||||
@@ -40,6 +40,8 @@ describe('MediaService', () => {
|
||||
config?: Record<string, unknown>;
|
||||
storageService?: Record<string, unknown>;
|
||||
mediaProbeService?: Record<string, unknown>;
|
||||
mediaRepository?: Record<string, jest.Mock>;
|
||||
postsService?: Record<string, jest.Mock>;
|
||||
} = {},
|
||||
) => {
|
||||
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<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 { 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<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(): {
|
||||
credentials?: any;
|
||||
} {
|
||||
|
||||
69
src/modules/media/schemas/ai-music-archive.schema.ts
Normal file
69
src/modules/media/schemas/ai-music-archive.schema.ts
Normal file
@@ -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 });
|
||||
المرجع في مشكلة جديدة
حظر مستخدم