Add AI music archive backend
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-06-30 14:41:50 +03:00
الأصل 568f679da1
التزام 7a754f4bf9
9 ملفات معدلة مع 775 إضافات و6 حذوفات

عرض الملف

@@ -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 { 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;
} {

عرض الملف

@@ -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 });